================================================================================
GUIDE COMPLET D'ANALYSE DE DONNÉES AVEC PYTHON
MANUEL UNIVERSITAIRE & PROFESSIONNEL — NIVEAU DÉBUTANT À EXPERT
================================================================================
PARTIE 1 — INTRODUCTION À LA DATA ET À PYTHON
================================================================================
Auteur : Guide Pédagogique Data Science
Public cible : Étudiant débutant en génie logiciel
Prérequis : Aucun
================================================================================

TABLE DES MATIÈRES — PARTIE 1
──────────────────────────────
Chapitre 1  : Qu'est-ce que la data ?
Chapitre 2  : Types de données
Chapitre 3  : Cycle de vie des données
Chapitre 4  : Introduction à Python
Chapitre 5  : Installation de l'environnement

================================================================================
CHAPITRE 1 — QU'EST-CE QUE LA DATA ?
================================================================================

────────────────────────────────────────
1.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────

Qu'est-ce que la "data" (donnée) ?

Une donnée est une représentation brute d'un fait, d'une mesure ou d'une
observation. La donnée, seule, n'a pas de sens immédiat. C'est son traitement,
son analyse et son interprétation qui lui donnent de la valeur.

Exemple concret :
  - "37.5" est une donnée brute
  - "37.5°C — température corporelle d'un patient" devient une information
  - "Fièvre légère détectée, surveiller le patient" devient un insight

Différence clé :
  - Donnée : valeur brute, non interprétée, isolée
  - Information : donnée contextualisée, interprétée, qui a un sens
  - Insight : information analysée, qui permet de prendre une décision


Pourquoi la data existe-t-elle ?
  La data existe parce que le monde produit en permanence des événements
  mesurables : transactions financières, clics sur un site web, capteurs
  industriels, signaux médicaux, interactions sociales...

  Ces événements, une fois capturés et structurés, permettent de :
    - Comprendre le passé (analyse rétrospective)
    - Comprendre le présent (monitoring en temps réel)
    - Prédire le futur (modèles prédictifs)
    - Prendre de meilleures décisions (data-driven decision making)

────────────────────────────────────────────────────────────────────────────────
EXPLICATION : LA DATA EXISTE PARCE QUE LE MONDE PRODUIT DES ÉVÉNEMENTS MESURABLES
────────────────────────────────────────────────────────────────────────────────

Phrase à expliquer :
"La data existe parce que le monde produit en permanence des événements mesurables"

Décomposition :

1. Le monde produit en permanence des événements

   - Chaque jour, dans notre vie ou dans les systèmes industriels, il se passe des milliers, voire des millions d’événements.
   - Exemple :
     - Une personne fait un achat en ligne.
     - Un smartphone enregistre un pas ou une localisation.
     - Une machine industrielle mesure la température ou la pression.
     - Un patient prend sa tension artérielle.
   - Tous ces événements sont des faits observables.

2. Ces événements sont mesurables

   - "Mesurable" signifie qu’ils peuvent être capturés sous forme de données.
   - Chaque événement peut être converti en chiffres, texte, images, sons, ou signaux numériques.
   - Exemple :
     - Achat en ligne -> montant, date, produit
     - Pas du smartphone -> 5000 pas aujourd’hui
     - Température corporelle -> 37.5°C
     - Capteur industriel -> 1,2 bar de pression

3. Donc la data existe

   - Chaque mesure devient une donnée brute dès qu’elle est enregistrée.
   - Sans ces événements mesurables, il n’y aurait rien à analyser, rien à transformer en information.
   - C’est la collecte de ces événements qui alimente l’univers de la data.

Exemple concret :

-------------------------------------------------------------------------------------------------------------------
Événement                        | Donnée capturée            | Explication
-------------------------------------------------------------------------------------------------------------------
Achat en ligne                   | 25€, 17 mars, "chaussures" | Chaque achat produit une donnée mesurable : montant, 
                                 |                            | date, produit
-------------------------------------------------------------------------------------------------------------------
Pas quotidien sur smartphone     | 5 000 pas                   | L’événement “marcher” est transformé en nombre -> donnée
-------------------------------------------------------------------------------------------------------------------
Température d’un patient         | 37,5°C                     | L’événement “mesure du corps” devient donnée
-------------------------------------------------------------------------------------------------------------------
Publication sur un réseau social | Texte + date + likes       | Chaque post est un événement observable, mesurable, 
                                 |                            | transformé en données
-------------------------------------------------------------------------------------------------------------------

Résumé pédagogique :

- La data n’existe pas par hasard.
- Elle est la trace des événements mesurables dans le monde réel.
- Sans événements mesurables, il n’y aurait aucune donnée à analyser ni information à générer.

COMPRENDRE L'UTILITE DE LA DATA

--------------------------------------------------
INTRODUCTION
--------------------------------------------------

Les événements du monde réel, une fois capturés et transformés en données,
permettent d’extraire de la valeur à différents niveaux.
Ces niveaux correspondent à différentes manières d’exploiter la data.

--------------------------------------------------
- COMPRENDRE LE PASSE (ANALYSE RETROSPECTIVE)
--------------------------------------------------

Définition :
Analyser les données passées pour comprendre ce qui s’est déjà produit.

Objectif :
- Identifier des tendances
- Comprendre les causes d’un résultat
- Analyser les performances passées

Exemple :
Une entreprise analyse ses ventes des 12 derniers mois pour voir quels produits
se vendent le mieux.

Pourquoi c’est important :
Cela permet d’apprendre du passé et d’éviter de reproduire les erreurs.

--------------------------------------------------
- COMPRENDRE LE PRESENT (MONITORING EN TEMPS REEL)
--------------------------------------------------

Définition :
Observer les données en temps réel pour suivre ce qui se passe actuellement.

Objectif :
- Surveiller des systèmes
- Détecter des anomalies rapidement
- Réagir immédiatement

Exemple :
Une application de livraison suit en temps réel la position des livreurs.

Pourquoi c’est important :
Permet de réagir rapidement en cas de problème (panne, fraude, bug).

--------------------------------------------------
- PREDIRE LE FUTUR (MODELES PREDICTIFS)
--------------------------------------------------

Définition :
Utiliser les données passées pour anticiper des événements futurs.

Objectif :
- Faire des prévisions
- Anticiper la demande
- Réduire les risques

Exemple :
Prédire les ventes du mois prochain à partir des ventes passées.

Pourquoi c’est important :
Permet de se préparer à l’avance et d’optimiser les ressources.

--------------------------------------------------
- PRENDRE DE MEILLEURES DECISIONS (DATA-DRIVEN)
--------------------------------------------------

Définition :
Utiliser les données pour prendre des décisions basées sur des faits
plutôt que sur l’intuition.

Objectif :
- Réduire l’incertitude
- Améliorer la qualité des décisions
- Optimiser les performances

Exemple :
Décider d’augmenter le stock d’un produit car les données montrent une forte demande.

Pourquoi c’est important :
Les décisions basées sur les données sont généralement plus fiables
et plus efficaces.

--------------------------------------------------
RESUME GLOBAL
--------------------------------------------------

Données -> Analyse -> Compréhension -> Action

- Passé : comprendre ce qui s’est produit
- Présent : surveiller ce qui se passe
- Futur : anticiper ce qui va se produire
- Décision : agir intelligemment grâce aux données

--------------------------------------------------
CONCLUSION
--------------------------------------------------

La data permet de transformer des événements bruts en connaissances utiles.
Elle est essentielle pour comprendre, anticiper et décider efficacement
dans un environnement complexe.

────────────────────────────────────────
1.2 EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────

La pyramide DIKW (Data -> Information -> Knowledge -> Wisdom)

  WISDOM (Sagesse)        <- Pourquoi ? Prise de décision éclairée
       ^
  KNOWLEDGE (Connaissance) <- Comment ? Patterns, règles, modèles
       ^
  INFORMATION              <- Quoi ? Données organisées, contextualisées
       ^
  DATA (Données)           <- Faits bruts, mesures, observations

Exemple fil rouge (e-commerce) :

DATA  : [(Lundi,0), (Mardi,0), (Mercredi,0), (Jeudi,0), (Vendredi,1), (Samedi,1), (Dimanche,1)] (clics sur bouton "Acheter")

INFORMATION :
- Total achats = 3
- Total jours = 7
- Taux global = 3/7 ≈ 43%

KNOWLEDGE :
- Semaine (Lundi -> Vendredi) : 1/5 = 20%
- Week-end (Samedi + Dimanche) : 2/2 = 100%
-> Le taux d'achat est beaucoup plus élevé le week-end

WISDOM :
- Lancer les promotions le vendredi soir
- Augmenter les campagnes marketing le week-end
- Adapter le stock pour le week-end

────────────────────────────────────────
PYRAMIDE DIKW — EXPLICATION ULTRA DETAILLEE
────────────────────────────────────────

--------------------------------------------------
INTRODUCTION
--------------------------------------------------

La pyramide DIKW représente la transformation des données brutes en
décisions intelligentes.

DATA -> INFORMATION -> KNOWLEDGE -> WISDOM

Plus on monte dans la pyramide :
- Plus la valeur augmente
- Plus la compréhension est forte
- Plus la décision devient possible

--------------------------------------------------
DATA (DONNEES BRUTES)
--------------------------------------------------

Définition :
Les données sont des faits bruts, sans interprétation.

Caractéristiques :
- Isolées
- Sans contexte
- Peu exploitables directement

Exemple :
[(Lundi,0), (Mardi,0), (Mercredi,0), (Jeudi,0), (Vendredi,1), (Samedi,1), (Dimanche,1)]

Ici :
1 = clic sur "Acheter"
0 = pas de clic

--------------------------------------------------
INFORMATION
--------------------------------------------------

Définition :
Les données deviennent information lorsqu’on leur donne du sens.

Transformation :
- Organisation
- Calcul
- Agrégation

Exemple :
3 achats sur 7 jours -> taux = 43%

Résultat :
On comprend ce qui se passe.

Limite :
On ne sait pas encore pourquoi.

--------------------------------------------------
KNOWLEDGE (CONNAISSANCE)
--------------------------------------------------

Définition :
La connaissance correspond à la compréhension des tendances.

Transformation :
- Analyse
- Comparaison
- Détection de patterns

Exemple :
- Taux semaine = 20%
- Taux week-end = 100%
-> Les utilisateurs achètent beaucoup plus le week-end

Résultat :
On comprend le comportement des utilisateurs.

--------------------------------------------------
WISDOM (SAGESSE)
--------------------------------------------------

Définition :
La sagesse consiste à utiliser la connaissance pour prendre une décision.

Transformation :
- Interprétation stratégique
- Prise de décision

Exemple :
- Lancer des promotions le vendredi soir
- Renforcer les campagnes marketing le week-end

Résultat :
Action concrète basée sur les données.

--------------------------------------------------
LOGIQUE GLOBALE
--------------------------------------------------

DATA -> INFORMATION -> KNOWLEDGE -> WISDOM

- DATA : Quoi ?
- INFORMATION : Que se passe-t-il ?
- KNOWLEDGE : Comment ?
- WISDOM : Pourquoi agir ?

--------------------------------------------------
ANALOGIE SIMPLE
--------------------------------------------------

DATA -> 37.5
INFORMATION -> température du patient
KNOWLEDGE -> fièvre légère
WISDOM -> donner un traitement

--------------------------------------------------
CONCLUSION
--------------------------------------------------

Les données seules ne suffisent pas.

La vraie valeur de la data est atteinte lorsqu’elle permet de :
- comprendre une situation
- détecter des tendances
- prendre des décisions intelligentes

Différence entre données structurées et non structurées :

  DONNÉES STRUCTURÉES
    - Organisées dans un format prédéfini (lignes et colonnes)
    - Stockées dans des bases de données relationnelles (SQL)
    - Exemples : tables Excel, CSV, bases SQL
    - Facilement interrogeables avec SQL ou Pandas

  DONNÉES SEMI-STRUCTURÉES
    - Format partiellement organisé
    - Exemples : JSON, XML, YAML, emails
    - Nécessitent un parsing avant analyse

  DONNÉES NON STRUCTURÉES
    - Sans format prédéfini
    - Exemples : textes libres, images, vidéos, audio
    - Nécessitent des techniques avancées (NLP, Computer Vision)

DIFFERENCE ENTRE DONNEES STRUCTUREES, SEMI-STRUCTUREES ET NON STRUCTUREES

--------------------------------------------------
a. DONNEES STRUCTUREES
--------------------------------------------------

Définition :
Ce sont des données bien organisées dans un format fixe, comme un tableau.

Structure :
- Lignes = observations (ex: personnes)
- Colonnes = variables (ex: nom, âge)

Exemple :

Nom     | Âge | Ville
--------|-----|-------
Ali     | 25  | Dakar
Marie   | 30  | Paris

Ici, tout est prévisible et ordonné.

Outils utilisés :
- SQL (bases de données relationnelles)
- Excel / CSV
- Pandas (Python)

Avantages :
- Faciles à stocker
- Faciles à analyser
- Rapides à interroger

Limites :
- Peu flexibles (structure rigide)

--------------------------------------------------
b. DONNEES SEMI-STRUCTUREES
--------------------------------------------------

Définition :
Données partiellement organisées, mais pas sous forme de tableau strict.

Exemple (JSON) :

{
  "nom": "Ali",
  "age": 25,
  "adresse": {
    "ville": "Dakar",
    "pays": "Sénégal"
  }
}

Explication :
- Il existe une structure (clés/valeurs)
- Mais elle peut varier d’un objet à l’autre

Autres exemples :
- XML
- YAML
- Emails
- Logs

Particularité :
Il faut faire du parsing (lecture et interprétation) avant analyse.

Avantages :
- Plus flexibles que les données structurées
- Adaptées aux données complexes

Limites :
- Plus difficiles à analyser
- Moins standardisées

--------------------------------------------------
c. DONNEES NON STRUCTUREES
--------------------------------------------------

Définition :
Données sans aucune organisation prédéfinie.

Exemples :
- Texte libre (articles, messages)
- Images
- Vidéos
- Audio

Exemple de texte :
"Aujourd’hui il fait chaud à Dakar et je vais à la plage"

Problème :
Impossible de mettre directement ces données dans un tableau sans transformation.

--------------------------------------------------
POURQUOI C’EST COMPLIQUE ?
--------------------------------------------------

Ces données :
- N’ont pas de colonnes
- N’ont pas de structure claire
- Sont souvent ambiguës

--------------------------------------------------
TECHNIQUES UTILISEES
--------------------------------------------------

Pour exploiter ces données, on utilise :

Natural Language Processing (NLP) :
- Analyse de texte
- Exemple : analyse de sentiment, résumé

Computer Vision :
- Analyse d’images et de vidéos
- Exemple : reconnaissance faciale

--------------------------------------------------
COMPARAISON
--------------------------------------------------

--------------------|------------------|----------------|------------
Type                | Structure        | Exemple        | Difficulté
--------------------|------------------|----------------|------------
Structurées         | Très organisée   | Excel, SQL     | Facile
Semi-structurées    | Partielle        | JSON, XML      | Moyen
Non structurées     | Aucune           | Texte, image   | Difficile
--------------------|------------------|----------------|------------

--------------------------------------------------
RESUME
--------------------------------------------------

- Structuré : Tableau propre (facile)
- Semi-structuré : Organisé mais flexible (moyen)
- Non structuré : Désordre total (complexe)

--------------------------------------------------
ASTUCE
--------------------------------------------------

Imagine une bibliothèque :

- Structuré : Livres bien classés par catégorie
- Semi-structuré : Livres avec étiquettes mais mélangés
- Non structuré : Pile de livres en vrac


Volume de données dans le monde :
  - En 2020 : 40 zettabytes de données créées
  - En 2025 : estimation de 175 zettabytes
  - 1 zettabyte = 1 000 000 000 000 gigabytes
  - 90% des données mondiales ont été créées dans les 2 dernières années

────────────────────────────────────────
1.3 COMMENT ÇA FONCTIONNE ? — PIPELINE DATA
────────────────────────────────────────

Le cycle complet d'une analyse de données :

  Sources de données
  (CSV, API, BDD, capteurs)
         v
  Collecte & Ingestion
  (scripts Python, ETL)
         v
  Stockage
  (fichiers, bases de données)
         v
  Nettoyage (Cleaning)
  (gestion NaN, doublons, outliers)
         v
  Transformation
  (normalisation, encodage, feature engineering)
         v
  Analyse & Exploration (EDA)
  (statistiques, corrélations)
         v
  Visualisation
  (graphiques, dashboards)
         v
  Modélisation (optionnel)
  (machine learning)
         v
  Insights & Décisions
  (rapports, recommandations)


--------------------------------------------------
1.3.1. INTRODUCTION
--------------------------------------------------

Le pipeline data représente le cycle complet d'une analyse de données,
depuis la collecte jusqu'à la prise de décision.

Chaque étape est essentielle. Sauter une étape peut produire des résultats incorrects.

--------------------------------------------------
1.3.2. ETAPES DU PIPELINE DATA
--------------------------------------------------

1. Sources de données :
Les données proviennent de différentes sources :
- Fichiers CSV
- API
- Bases de données (BDD)
- Capteurs

--------------------------------------------------

2. Collecte et Ingestion :
Les données sont récupérées et importées dans le système.

Outils et méthodes :
- Scripts Python
- Processus ETL (Extract, Transform, Load)

--------------------------------------------------

3. Stockage :
Les données sont stockées pour être utilisées plus tard.

Exemples :
- Fichiers (CSV, JSON)
- Bases de données (SQL, NoSQL)

--------------------------------------------------

4. Nettoyage (Cleaning) :
Les données sont préparées pour éviter les erreurs.

Actions principales :
- Gestion des valeurs manquantes (NaN)
- Suppression des doublons
- Traitement des valeurs aberrantes (outliers)

--------------------------------------------------

5. Transformation :
Les données sont modifiées pour être exploitables.

Exemples :
- Normalisation
- Encodage des variables
- Feature engineering (création de nouvelles variables)

--------------------------------------------------

6. Analyse et Exploration (EDA) :
On explore les données pour comprendre leur structure.

Actions :
- Calcul de statistiques
- Analyse des corrélations
- Détection de tendances

--------------------------------------------------

7. Visualisation :
Les données sont représentées graphiquement.

Exemples :
- Graphiques (courbes, histogrammes)
- Tableaux de bord (dashboards)

--------------------------------------------------

8. Modélisation (optionnel) :
Utilisation de modèles pour faire des prédictions.

Exemples :
- Machine Learning
- Algorithmes prédictifs

--------------------------------------------------

9. Insights et Décisions :
Interprétation des résultats pour aider à la prise de décision.

Résultats :
- Rapports
- Recommandations


--------------------------------------------------
POURQUOI UNE ETAPE DE STOCKAGE ?
--------------------------------------------------

Question :
Pourquoi ajouter une étape de stockage alors que les données sont déjà stockées dans les sources ?

Réponse :
Les sources de données représentent un stockage brut et externe.
Cependant, ces données ne sont pas forcément adaptées à une analyse directe.

Problèmes des sources :
- Données dispersées (plusieurs fichiers, API, bases)
- Accès lent ou instable
- Formats hétérogènes
- Données qui peuvent changer dans le temps

Rôle du stockage dans le pipeline :
Le stockage consiste à copier, centraliser et organiser les données
dans un système optimisé pour l’analyse.

Objectifs :
- Regrouper toutes les données au même endroit
- Améliorer les performances d’accès
- Stabiliser les données pour garantir la reproductibilité
- Faciliter le nettoyage et la transformation

Exemple :
Avant :
- API météo (temps réel)
- Fichier CSV (historique)
- Base SQL (utilisateurs)

Après stockage :
- Toutes les données sont regroupées dans une base ou un système centralisé

Résumé :
- Sources de données = stockage initial (brut)
- Stockage pipeline = stockage optimisé pour l’analyse

Conclusion :
Le stockage dans le pipeline permet de transformer des données brutes,
dispersées et instables en données fiables, centralisées et exploitables.


--------------------------------------------------
POINT IMPORTANT
--------------------------------------------------

Chaque étape du pipeline est critique.

Ignorer ou négliger une étape peut entraîner :
- Des analyses incorrectes
- Des modèles biaisés
- De mauvaises décisions

────────────────────────────────────────
1.4 POURQUOI LA DATA EST-ELLE IMPORTANTE ?
────────────────────────────────────────

Avantages de l'analyse de données :
  1. Décisions basées sur des faits (pas sur l'intuition)
  2. Détection de patterns invisibles à l'œil nu
  3. Automatisation de l'intelligence
  4. Compétitivité accrue
  5. Réduction des coûts et optimisation

Cas réels par secteur :
  BUSINESS    : Netflix analyse vos habitudes pour recommander des films
  FINANCE     : Détection de fraudes bancaires en temps réel
  SANTÉ       : Prédiction de maladies à partir de données génomiques
  TRANSPORT   : Optimisation des routes Uber/Waze
  AGRICULTURE : Capteurs IoT pour optimiser l'irrigation
  SPORT       : Moneyball — recrutement de joueurs par statistiques

IMPORTANCE DE LA DATA (ANALYSE DE DONNEES)

--------------------------------------------------
1.4.1. INTRODUCTION
--------------------------------------------------

La data (les données) est aujourd’hui au cœur de toutes les décisions
dans les entreprises, les gouvernements et les organisations.

L’analyse de données permet de transformer des informations brutes
en connaissances exploitables pour prendre de meilleures décisions.

--------------------------------------------------
1.4.2. AVANTAGES DE L’ANALYSE DE DONNEES
--------------------------------------------------

1. Décisions basées sur des faits :
Au lieu de se baser sur l’intuition ou l’expérience seule,
les décisions sont prises à partir de données concrètes et mesurables.

Exemple :
Une entreprise peut analyser ses ventes pour décider quels produits conserver.

--------------------------------------------------

2. Détection de patterns invisibles :
Les données permettent de révéler des tendances ou relations
que l’œil humain ne peut pas détecter facilement.

Exemple :
Identifier qu’un produit se vend mieux à certaines périodes ou dans certaines régions.

--------------------------------------------------

3. Automatisation de l’intelligence :
Les algorithmes peuvent analyser les données automatiquement
et prendre des décisions sans intervention humaine.

Exemple :
Systèmes de recommandation ou détection automatique de fraude.

--------------------------------------------------

4. Compétitivité accrue :
Les entreprises qui exploitent bien leurs données prennent
de meilleures décisions et devancent leurs concurrents.

Exemple :
Optimisation des stratégies marketing basées sur le comportement des clients.

--------------------------------------------------

5. Réduction des coûts et optimisation :
L’analyse de données permet d’identifier les inefficacités
et d’optimiser les ressources.

Exemple :
Réduction du gaspillage ou amélioration des processus logistiques.

--------------------------------------------------
1.4.3. CAS REELS PAR SECTEUR
--------------------------------------------------

BUSINESS :
Les entreprises analysent les comportements des utilisateurs
pour proposer des produits personnalisés.

Exemple :
Netflix recommande des films en fonction de votre historique de visionnage.

--------------------------------------------------

FINANCE :
Les banques utilisent les données pour détecter les activités suspectes.

Exemple :
Détection de fraudes bancaires en temps réel à partir des transactions.

--------------------------------------------------

SANTE :
Les données médicales permettent d’améliorer les diagnostics
et de prédire certaines maladies.

Exemple :
Analyse de données génomiques pour anticiper des risques de maladie.

--------------------------------------------------

TRANSPORT :
Les applications utilisent les données pour améliorer les déplacements.

Exemple :
Optimisation des trajets et du trafic en temps réel (Uber, Waze).

--------------------------------------------------

AGRICULTURE :
Les capteurs et données environnementales permettent d’optimiser la production.

Exemple :
Utilisation de capteurs IoT pour ajuster l’irrigation des cultures.

--------------------------------------------------

SPORT :
Les statistiques sont utilisées pour améliorer les performances
et prendre des décisions stratégiques.

Exemple :
Recrutement de joueurs basé sur l’analyse de données (approche Moneyball).

--------------------------------------------------
1.4.4. CONCLUSION
--------------------------------------------------

La data est un levier stratégique majeur dans tous les secteurs.

Elle permet :
- De mieux comprendre le monde
- D’anticiper les problèmes
- D’optimiser les performances
- De prendre des décisions fiables

Une organisation qui exploite bien ses données a un avantage considérable
dans un environnement de plus en plus compétitif.

────────────────────────────────────────
1.5 VOCABULAIRE ESSENTIEL À MAÎTRISER
────────────────────────────────────────

  Dataset          : Ensemble de données organisées
  Observation      : Une ligne dans un dataset (= un enregistrement)
  Variable/Feature : Une colonne dans un dataset (= un attribut)
  Valeur           : L'intersection d'une ligne et d'une colonne
  NaN/Null         : Valeur manquante
  Outlier          : Valeur aberrante, très éloignée de la normale
  Distribution     : Comment les valeurs sont réparties
  Corrélation      : Relation statistique entre deux variables
  Biais            : Distorsion systématique dans les données
  Pipeline         : Séquence automatisée d'opérations sur les données
  ETL              : Extract, Transform, Load — processus de transformation
  EDA              : Exploratory Data Analysis — analyse exploratoire

VOCABULAIRE ESSENTIEL A MAITRISER

--------------------------------------------------
1.5.1. INTRODUCTION
--------------------------------------------------

Pour bien comprendre et travailler avec les données,
il est important de maîtriser certains termes clés.
Ce vocabulaire est utilisé dans la plupart des projets
d’analyse de données et de data science.

--------------------------------------------------
1.5.2. DEFINITIONS AVEC EXEMPLES
--------------------------------------------------

Dataset :
Ensemble de données organisées dans un format structuré (tableau).
Exemple :
| Nom   | Âge | Ville |
|-------|-----|-------|
| Ali   | 25  | Dakar |
| Marie | 30  | Paris |

Observation :
Une ligne dans un dataset, correspondant à un enregistrement ou un individu.
Exemple :
Dans le dataset ci-dessus, la ligne `Ali | 25 | Dakar` est une observation.

Variable / Feature :
Une colonne dans un dataset, représentant un attribut ou une caractéristique.
Exemple :
La colonne `Âge` est une variable du dataset.

Valeur :
L'intersection d'une ligne et d'une colonne, c’est-à-dire le contenu d’une cellule.
Exemple :
Dans la ligne `Marie | 30 | Paris`, la valeur de la variable `Ville` est `Paris`.

NaN / Null :
Valeur manquante dans les données, qui nécessite souvent un traitement spécifique.
Exemple :
| Nom   | Âge | Ville  |
|-------|-----|--------|
| Ali   | 25  | Dakar  |
| Marie | NaN | Paris  |
Ici, l’âge de Marie est manquant (NaN).

Outlier :
Valeur aberrante, très éloignée de la tendance générale des données.
Exemple :
Si la plupart des âges sont entre 20 et 40 ans, mais une valeur est `120`, c’est un outlier.

Distribution :
Façon dont les valeurs d’une variable sont réparties (ex: histogramme).
Exemple :
Pour la variable `Âge`, 50% des individus ont moins de 30 ans et 50% ont plus de 30 ans.

Corrélation :
Relation statistique entre deux variables. Indique si elles varient ensemble.
Exemple :
Si la taille et le poids sont mesurés, une corrélation positive signifie que plus la taille augmente, plus le poids augmente.

Biais :
Distorsion systématique dans les données qui peut influencer les résultats.
Exemple :
Si un sondage politique ne recueille que l’avis des jeunes, le résultat est biaisé.

Pipeline :
Séquence automatisée d’opérations appliquées sur les données,
de la collecte à l’analyse.
Exemple :
Collecte des données -> Nettoyage -> Transformation -> Analyse -> Visualisation

ETL (Extract, Transform, Load) :
Processus de transformation des données :
- Extract : extraire les données des sources
- Transform : nettoyer et modifier les données
- Load : stocker les données prêtes à l’analyse
Exemple :
Extraire les ventes depuis une API -> convertir les dates -> charger dans une base SQL.

EDA (Exploratory Data Analysis) :
Analyse exploratoire des données pour comprendre leur structure,
leurs tendances et détecter anomalies ou relations.
Exemple :
Créer des histogrammes, calculer les corrélations et résumer les statistiques d’un dataset de ventes.

--------------------------------------------------
1.5.3. EXEMPLE DE COMMUNICATION D’EQUIPE
--------------------------------------------------

Contexte : Une équipe de data analyse un dataset de ventes pour préparer un rapport.

Alice (Data Analyst) :
"Salut l’équipe, j’ai chargé le **dataset** des ventes du dernier trimestre. Chaque **observation** correspond à une transaction et chaque **variable** inclut `Produit`, `Montant` et `Date`."

Bob (Data Engineer) :
"Parfait. J’ai remarqué que certaines lignes ont des **NaN** dans `Montant`. On doit décider comment gérer ces valeurs manquantes avant de continuer."

Clara (Data Scientist) :
"Oui, on peut soit les remplir par la moyenne, soit les exclure. Aussi, j’ai identifié un **outlier** : une vente à 100 000 € alors que la moyenne est autour de 500 €. On doit vérifier si c’est une erreur ou une vraie vente."

Alice :
"Bonne idée. Ensuite, on peut regarder la **distribution** des ventes et calculer la **corrélation** entre `Montant` et `Nombre d’articles` pour voir si les grosses commandes sont fréquentes."

Bob :
"Attention au **biais** : ce dataset ne contient que les ventes en ligne. Les ventes en boutique ne sont pas incluses."

Clara :
"Exact. Pour rappel, on suit le **pipeline** habituel : extraction depuis la base -> nettoyage -> transformation -> analyse -> visualisation."

Alice :
"J’ai mis en place un petit script **ETL** pour ça, donc on peut charger rapidement les données propres dans notre environnement."

Clara :
"Parfait, après ça, on pourra faire l’**EDA** et créer des graphiques pour notre présentation."

Bob :
"Super ! On pourra ainsi partager des insights fiables avec le manager et lui expliquer nos décisions basées sur des faits."


Ce dialogue montre comment une équipe utilise le vocabulaire pour :
- Décrire la structure des données (dataset, observation, variable, valeur)
- Identifier et traiter les problèmes (NaN, outlier, distribution, corrélation, biais)
- Décrire le processus (pipeline, ETL, EDA)
- Communiquer efficacement et prendre des décisions basées sur les données

--------------------------------------------------
1.5.4. CONCLUSION
--------------------------------------------------

Maîtriser ce vocabulaire et ses exemples est essentiel pour :
- Lire et comprendre les datasets
- Communiquer efficacement avec des équipes data
- Réaliser des analyses fiables et structurées

────────────────────────────────────────
1.6 EXERCICES PRATIQUES — CHAPITRE 1
────────────────────────────────────────

EXERCICES FACILES :
  1. Identifiez 5 sources de données dans votre vie quotidienne
     (ex: historique d'appels, steps smartphone, achats en ligne)

  2. Pour chaque source, indiquez si les données sont structurées,
     semi-structurées ou non structurées

  3. Choisissez une entreprise et décrivez comment elle pourrait
     utiliser la data pour améliorer ses services

EXERCICES INTERMÉDIAIRES :
  4. Construisez votre propre pyramide DIKW pour ce cas :
     "Les ventes d'un magasin de chaussures"

  5. Listez les biais potentiels dans un sondage politique

  6. Décrivez le pipeline data complet pour analyser les avis clients
     d'un restaurant

EXERCICES AVANCÉS :
  7. Comparez les avantages et inconvénients de stocker des données
     en CSV vs base de données SQL

  8. Proposez une architecture data complète pour une startup e-commerce
     avec 10 000 produits et 50 000 clients

  9. Analysez les enjeux éthiques de la collecte de données personnelles
     (RGPD, consentement, anonymisation)

────────────────────────────────────────
1.7 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────

CORRIGÉ EXERCICE 1 :
  Sources quotidiennes :
    - Historique d'appels -> structurée (numéro, durée, date, heure)
    - Géolocalisation smartphone -> semi-structurée (JSON GPS)
    - Messages WhatsApp -> non structurée (texte libre)
    - Montre connectée -> structurée (FC, pas, calories)
    - Historique Netflix -> structurée (titre, durée, date, note)

CORRIGÉ EXERCICE 4 (Pyramide DIKW — Magasin chaussures) :
  DATA        : 143, 87, 212, 56 (nombres de paires vendues par modèle)
  INFORMATION : Le modèle "Running Pro" est le plus vendu (212 paires)
  KNOWLEDGE   : Les chaussures de running se vendent mieux en janvier
                et septembre (rentrée sportive)
  WISDOM      : Commander 40% de stock en plus de running en décembre
                pour anticiper la demande de janvier

================================================================================
CHAPITRE 2 — TYPES DE DONNÉES
================================================================================

────────────────────────────────────────
2.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────

Comprendre les types de données est FONDAMENTAL car :
  - Chaque type nécessite des techniques d'analyse différentes
  - Appliquer la mauvaise technique = résultats incorrects
  - Les algorithmes de ML ont des contraintes sur les types

────────────────────────────────────────
2.2 CLASSIFICATION DES DONNÉES — VERSION DÉTAILLÉE
────────────────────────────────────────

INTRODUCTION

En analyse de données, il est essentiel de comprendre le type de données
que l’on manipule. Cela détermine :

- Les méthodes d’analyse possibles
- Les calculs autorisés
- Les visualisations adaptées
- Les erreurs à éviter

Il existe deux grandes classifications :

1. Données quantitatives vs qualitatives
2. Niveaux de mesure (échelles de Stevens)

────────────────────────────────────────
2.2.1. DONNÉES QUANTITATIVES VS QUALITATIVES
────────────────────────────────────────

2.2.1.1 DONNÉES QUANTITATIVES (NUMÉRIQUES)

Définition :
Ce sont des données mesurables et exprimées sous forme de nombres.

Elles permettent de faire des calculs mathématiques.

Exemples :
- Âge
- Poids
- Taille
- Revenu
- Nombre de ventes

Types :

A. DISCRÈTES

Définition :
Valeurs entières, résultant d’un comptage.

Caractéristiques :
- Pas de valeurs intermédiaires
- Résultat d’un dénombrement

Exemples :
- Nombre d’enfants (0, 1, 2, 3...)
- Nombre de voitures
- Nombre de buts marqués

Important :
On ne peut pas avoir 2.5 enfants -> donc valeurs entières uniquement

----------------------------------------

B. CONTINUES

Définition :
Valeurs pouvant prendre une infinité de valeurs dans un intervalle.

Caractéristiques :
- Résultat d’une mesure
- Peut inclure des décimales

Exemples :
- Taille : 1.732 m
- Température : 37.5 °C
- Poids : 65.8 kg
- Temps : 2.35 secondes

Important :
Entre deux valeurs, il existe toujours une infinité de valeurs possibles

────────────────────────────────────────
2.2.1.2 DONNÉES QUALITATIVES (CATÉGORIELLES)
────────────────────────────────────────

Définition :
Données descriptives représentant des catégories ou des labels.

Elles ne sont pas numériques au sens mathématique.

Exemples :
- Couleur
- Pays
- Sexe
- Type de produit

Types :

A. NOMINALES

Définition :
Catégories sans ordre naturel.

Caractéristiques :
- Pas de hiérarchie
- Juste des étiquettes

Exemples :
- Couleur des yeux (bleu, vert, marron)
- Pays (Sénégal, France, Canada)
- Genre de film (action, comédie, drame)

Important :
On ne peut pas dire :
"Bleu > Vert" -> aucun sens

----------------------------------------

B. ORDINALES

Définition :
Catégories avec un ordre naturel.

Caractéristiques :
- Hiérarchie existante
- Mais distance inconnue entre les catégories

Exemples :
- Niveau d’études (primaire < secondaire < supérieur)
- Satisfaction (1 étoile < 2 étoiles < 3 étoiles...)
- Classement (1er, 2e, 3e)

Important :
On sait que 3 > 2, mais pas "de combien"

────────────────────────────────────────
2.2.2. NIVEAUX DE MESURE (ÉCHELLES DE STEVENS)
────────────────────────────────────────

Cette classification est plus précise et très importante en statistique.

────────────────────────────────────────
2.2.2.1 NOMINALE
────────────────────────────────────────

Définition :
Catégories sans ordre.

Exemples :
- Rouge, Bleu, Vert
- Homme, Femme
- Pays

Opérations autorisées :
- Égalité (=)
- Différence (≠)

Statistiques possibles :
- Mode (valeur la plus fréquente)
- Fréquence
- Tableaux croisés

Interdiction :
- Pas de moyenne
- Pas de comparaison numérique

────────────────────────────────────────
2.2.2.2 ORDINALE
────────────────────────────────────────

Définition :
Catégories avec ordre.

Exemples :
- Mauvais < Moyen < Bon < Excellent
- Classement

Opérations autorisées :
- Comparaison (<, >)

Statistiques possibles :
- Médiane
- Percentiles
- Rangs

Limite :
On ne peut pas mesurer l’écart exact entre les valeurs

────────────────────────────────────────
2.2.2.3 INTERVALLE
────────────────────────────────────────

Définition :
Données avec ordre + distances égales, mais sans zéro absolu.

Exemple :
- Température en Celsius

Important :
0°C ne signifie pas "absence de chaleur"

Opérations autorisées :
- Addition
- Soustraction

Statistiques possibles :
- Moyenne
- Écart-type

Erreur fréquente :
Dire que 20°C est deux fois plus chaud que 10°C -> FAUX

────────────────────────────────────────
2.2.2.4 RATIO
────────────────────────────────────────

Définition :
Données avec ordre + distance + zéro absolu réel.

Exemples :
- Poids (0 kg = absence de masse)
- Âge
- Revenu
- Distance

Opérations autorisées :
- Toutes (addition, soustraction, multiplication, division)

Statistiques possibles :
- Toutes (moyenne, variance, etc.)

Exemple valide :
40 kg est deux fois plus que 20 kg -> CORRECT

────────────────────────────────────────
3. POURQUOI C'EST IMPORTANT ?
────────────────────────────────────────

1. Éviter les erreurs d’analyse

- Calculer la moyenne de couleurs -> ABSURDE
- Comparer des catégories nominales -> sans sens

2. Choisir les bonnes méthodes statistiques

- Nominal -> fréquences
- Ordinal -> médiane
- Quantitatif -> moyenne, variance

3. Éviter les erreurs d’interprétation

- 20°C ≠ deux fois 10°C
- Kelvin (échelle ratio) permet cette comparaison

4. Améliorer la qualité des décisions

- Bonne compréhension -> meilleure analyse
- Mauvaise classification -> conclusions fausses

────────────────────────────────────────
RÉSUMÉ GLOBAL
────────────────────────────────────────

QUANTITATIF :
- Discret -> comptage
- Continu -> mesure

QUALITATIF :
- Nominal -> pas d’ordre
- Ordinal -> ordre sans distance

ÉCHELLES :
- Nominale -> catégories
- Ordinale -> ordre
- Intervalle -> distance sans zéro absolu
- Ratio -> tout (zéro absolu)

────────────────────────────────────────
CONCLUSION
────────────────────────────────────────

Bien classer ses données est une étape fondamentale en data analysis.

Une mauvaise classification entraîne :
- Des erreurs de calcul
- Des analyses incorrectes
- De mauvaises décisions

Une bonne classification permet :
- Une analyse correcte
- Des insights fiables
- Des décisions pertinentes

────────────────────────────────────────
2.3 TYPES DE DONNÉES EN PYTHON ET PANDAS — VERSION DÉTAILLÉE
────────────────────────────────────────

INTRODUCTION

En data analysis avec Python, il est essentiel de comprendre les types
de données. Chaque type détermine :

- Les opérations possibles
- La mémoire utilisée
- Les performances
- Le comportement des calculs

On distingue deux niveaux :
1. Types natifs Python
2. Types spécifiques à Pandas (dtypes)

────────────────────────────────────────
2.3.1. TYPES DE DONNÉES PYTHON NATIF
────────────────────────────────────────

Python fournit des types de base pour manipuler les données.

--------------------------------------------------
2.3.1.1 int (entier)
--------------------------------------------------

Définition :
Nombre entier (sans décimale)

Exemples :
42, -7, 0

Utilisation :
- Compter
- Indexer
- Stocker des quantités

Opérations possibles :
+ , - , * , // , %

Exemple :
5 + 3 = 8

--------------------------------------------------
2.3.1.2 float (nombre décimal)
--------------------------------------------------

Définition :
Nombre réel avec décimales

Exemples :
3.14, -0.5, 2.0e10

Important :
- Peut contenir des erreurs d’arrondi (précision limitée)

Exemple :
0.1 + 0.2 ≠ 0.3 (exactement)

Utilisation :
- Calculs scientifiques
- Mesures (poids, température)

--------------------------------------------------
2.3.1.3 str (chaîne de caractères)
--------------------------------------------------

Définition :
Texte encodé entre guillemets

Exemples :
"Bonjour", "Paris", "Data Science"

Opérations possibles :
- Concaténation (+)
- Longueur (len)
- Recherche

Exemple :
"Hello" + " World" = "Hello World"

--------------------------------------------------
2.3.1.4 bool (booléen)
--------------------------------------------------

Définition :
Valeur logique

Valeurs :
True, False

Utilisation :
- Conditions
- Filtres

Exemple :
5 > 3 -> True

--------------------------------------------------
2.3.1.5 list (liste)
--------------------------------------------------

Définition :
Collection ordonnée et modifiable

Exemple :
[1, 2, 3, "a"]

Caractéristiques :
- Indexée
- Modifiable (mutable)

Utilisation :
- Stocker plusieurs valeurs

Exemple :
liste[0] -> premier élément

--------------------------------------------------
2.3.1.6 dict (dictionnaire)
--------------------------------------------------

Définition :
Structure clé -> valeur

Exemple :
{"nom": "Alice", "age": 25}

Caractéristiques :
- Accès rapide par clé

Utilisation :
- Représenter des objets (utilisateur, produit)

--------------------------------------------------
2.3.1.7 tuple (tuple immuable)
--------------------------------------------------

Définition :
Liste non modifiable

Exemple :
(1, 2, 3)

Caractéristiques :
- Immutable (non modifiable)
- Plus rapide que list

Utilisation :
- Données fixes

--------------------------------------------------
2.3.1.8 None (valeur nulle)
--------------------------------------------------

Définition :
Absence de valeur

Exemple :
None

Important :
- Différent de 0 ou ""
- Utilisé pour représenter un vide

Lien avec data :
- Équivalent conceptuel de NaN

────────────────────────────────────────
2.3.2. TYPES DE DONNÉES PANDAS (DTYPES)
────────────────────────────────────────

Pandas optimise les types pour le traitement de données.

--------------------------------------------------
2.3.2.1 int64
--------------------------------------------------

Définition :
Entier codé sur 64 bits

Plage :
±9.2 × 10^18

Exemple :
100, 2500

Utilisation :
- Comptage
- Identifiants

--------------------------------------------------
2.3.2.2 float64
--------------------------------------------------

Définition :
Nombre décimal haute précision

Exemple :
3.1415926535

Utilisation :
- Calculs statistiques
- Moyennes, variances

--------------------------------------------------
2.3.2.3 object
--------------------------------------------------

Définition :
Type générique (souvent du texte)

Exemples :
"Paris", "Data"

Important :
- Peut contenir différents types (mixte)
- Moins performant

Problème courant :
Colonnes mal typées -> erreurs d’analyse

--------------------------------------------------
2.3.2.4 bool
--------------------------------------------------

Définition :
Valeur True ou False

Utilisation :
- Filtres
- Conditions

Exemple :
df["age"] > 18

--------------------------------------------------
2.3.2.5 datetime64
--------------------------------------------------

Définition :
Date et heure

Exemple :
"2024-01-15 10:30:00"

Utilisation :
- Analyse temporelle
- Séries chronologiques

Exemples d’opérations :
- extraire année/mois
- calculer des différences de dates

--------------------------------------------------
2.3.2.6 category
--------------------------------------------------

Définition :
Type optimisé pour les données répétitives

Exemple :
"Paris", "Dakar", "Paris", "Dakar"

Avantages :
- Moins de mémoire
- Plus rapide

Utilisation :
- Variables qualitatives (pays, villes, genres)

--------------------------------------------------
2.3.2.7 timedelta
--------------------------------------------------

Définition :
Durée entre deux dates

Exemple :
5 jours, 3 heures

Utilisation :
- Calcul de durée
- Analyse temporelle

Exemple :
date_fin - date_debut

────────────────────────────────────────
2.3.3. DIFFÉRENCE PYTHON VS PANDAS
────────────────────────────────────────

PYTHON :
- Flexible
- Généraliste
- Utilisé pour programmation

PANDAS :
- Optimisé pour data analysis
- Plus rapide sur gros datasets
- Gère mieux les colonnes

Exemple :
- Python -> list
- Pandas -> Series/DataFrame

────────────────────────────────────────
2.3.4. ERREURS COURANTES
────────────────────────────────────────

1. Mélanger types numériques et texte
   Exemple :
   "10" + 5 -> erreur

2. Mauvais dtype dans Pandas
   Exemple :
   colonne numérique en "object"

3. Mauvaise gestion des valeurs nulles
   None vs NaN

4. Problèmes de dates mal formatées

────────────────────────────────────────
2.3.5. POURQUOI C'EST IMPORTANT ?
────────────────────────────────────────

- Choisir le bon type améliore la performance
- Évite les erreurs de calcul
- Permet des analyses correctes
- Optimise la mémoire

────────────────────────────────────────
RÉSUMÉ
────────────────────────────────────────

Python :
- int, float, str, bool
- list, dict, tuple
- None

Pandas :
- int64, float64
- object, bool
- datetime64, category, timedelta

────────────────────────────────────────
CONCLUSION
────────────────────────────────────────

Maîtriser les types de données est essentiel pour :
- Écrire du code fiable
- Manipuler correctement les données
- Réaliser des analyses précises

Une mauvaise gestion des types peut entraîner :
- Des erreurs invisibles
- Des résultats faux
- Des pertes de performance

────────────────────────────────────────
2.4 IMPLÉMENTATION PRATIQUE COMMENTÉE
────────────────────────────────────────

```python
# ============================================================
# EXPLORATION DES TYPES DE DONNÉES EN PYTHON
# ============================================================

# --- Types Python natifs ---
age = 25              # int : nombre entier
taille = 1.75         # float : décimal
nom = "Alice"         # str : chaîne de caractères
etudiant = True       # bool : vrai ou faux
notes = [15, 18, 12]  # list : liste de valeurs
info = {"nom": "Alice", "age": 25}  # dict : clé-valeur

# Vérification des types
print(type(age))       # <class 'int'>
print(type(taille))    # <class 'float'>
print(type(nom))       # <class 'str'>

# --- Création d'un DataFrame avec différents types ---
import pandas as pd

data = {
    "nom":       ["Alice", "Bob", "Charlie", "Diana"],  # object (str)
    "age":       [25, 30, 22, 28],                      # int64
    "taille":    [1.65, 1.80, 1.72, 1.68],             # float64
    "etudiant":  [True, False, True, False],             # bool
    "niveau":    ["Master", "Licence", "Licence", "Doctorat"],  # object
    "date_nais": ["1999-03-15", "1994-07-22", "2002-01-10", "1996-11-05"]  # str -> à convertir
}

df = pd.DataFrame(data)

# Affichage du DataFrame
print(df)
#       nom  age  taille  etudiant    niveau   date_nais
# 0   Alice   25    1.65      True    Master  1999-03-15
# 1     Bob   30    1.80     False   Licence  1994-07-22
# 2  Charlie  22    1.72      True   Licence  2002-01-10
# 3    Diana   28    1.68     False  Doctorat  1996-11-05

# Vérification des types Pandas
print(df.dtypes)
# nom          object
# age           int64
# taille      float64
# etudiant       bool
# niveau       object
# date_nais    object  <- Pandas ne sait pas que c'est une date !

# Conversion correcte des types
df["date_nais"] = pd.to_datetime(df["date_nais"])  # Conversion string -> datetime
df["niveau"] = pd.Categorical(                      # Conversion string -> category
    df["niveau"],
    categories=["Licence", "Master", "Doctorat"],  # Ordre logique défini
    ordered=True                                    # Rend la variable ordinale
)

# Vérification après conversion
print(df.dtypes)
# nom                    object
# age                     int64
# taille                float64
# etudiant                 bool
# niveau               category
# date_nais      datetime64[ns]

# Utilisation de la variable ordinale
print(df["niveau"].sort_values())
# 2     Licence
# 1     Licence
# 0      Master
# 3    Doctorat

# Comparaison ordinale
print(df["niveau"] > "Licence")
# 0     True   (Master > Licence)
# 1    False   (Licence > Licence = False)
# 2    False
# 3     True   (Doctorat > Licence)

# Statistiques sur les types numériques
print(df["age"].describe())
# count     4.000000
# mean     26.250000
# std       3.304038
# min      22.000000
# 25%      24.250000
# 50%      26.500000
# 75%      28.500000
# max      30.000000

# Fréquences sur les catégories
print(df["niveau"].value_counts())
# Licence     2
# Master      1
# Doctorat    1
```

────────────────────────────────────────
EXERCICE PRATIQUE — DATAFRAME PYTHON / PANDAS
────────────────────────────────────────

Objectif :
- Manipuler un DataFrame
- Utiliser types numériques, booléens, catégoriels et datetime
- Faire des statistiques, filtrages et tris

DataFrame de référence :

       nom  age  taille  etudiant    niveau   date_nais
0    Alice   25    1.65      True    Master  1999-03-15
1      Bob   30    1.80     False   Licence  1994-07-22
2   Charlie  22    1.72      True   Licence  2002-01-10
3     Diana   28    1.68     False  Doctorat  1996-11-05

Types Pandas :
nom       -> object
age       -> int64
taille    -> float64
etudiant  -> bool
niveau    -> category (ordonné)
date_nais -> datetime64[ns]

────────────────────────────────────────
TÂCHES À RÉALISER
────────────────────────────────────────

1. Calculer l’âge moyen des étudiants et non-étudiants séparément
2. Filtrer les personnes dont l’âge > 25 et qui sont étudiants
3. Trier le DataFrame par "niveau" (ordre : Licence < Master < Doctorat)
4. Compter le nombre de personnes par "niveau"
5. Ajouter une colonne "année_naissance" à partir de "date_nais"
6. Calculer la taille moyenne des personnes dont le "niveau" = Master
7. Comparer les âges pour savoir qui est plus âgé que "Alice"

────────────────────────────────────────
CORRIGE DETAILLE
────────────────────────────────────────

import pandas as pd

# Recréation du DataFrame
data = {
    "nom":       ["Alice", "Bob", "Charlie", "Diana"],
    "age":       [25, 30, 22, 28],
    "taille":    [1.65, 1.80, 1.72, 1.68],
    "etudiant":  [True, False, True, False],
    "niveau":    pd.Categorical(
                    ["Master", "Licence", "Licence", "Doctorat"],
                    categories=["Licence", "Master", "Doctorat"],
                    ordered=True),
    "date_nais": pd.to_datetime(["1999-03-15", "1994-07-22", "2002-01-10", "1996-11-05"])
}

df = pd.DataFrame(data)

# 1. Age moyen des étudiants et non-étudiants
moyenne_age = df.groupby("etudiant")["age"].mean()
print("Moyenne âge par statut étudiant :")
# True -> (25+22)/2 = 23.5
# False -> (30+28)/2 = 29.0

# 2. Filtrer : âge > 25 ET étudiant
filtre = df[(df["age"] > 25) & (df["etudiant"] == True)]
print("Personnes > 25 ans et étudiants :")
# Résultat : aucun (empty DataFrame)

# 3. Trier par "niveau"
df_trie = df.sort_values("niveau")
print("Tri par niveau :")
# Ordre : Licence -> Master -> Doctorat

# 4. Compter le nombre par "niveau"
count_niveau = df["niveau"].value_counts()
print("Nombre de personnes par niveau :")
# Licence 2, Master 1, Doctorat 1

# 5. Ajouter "année_naissance"
df["année_naissance"] = df["date_nais"].dt.year
print(df[["nom", "année_naissance"]])
# Alice 1999, Bob 1994, Charlie 2002, Diana 1996

# 6. Taille moyenne pour "niveau" = Master
taille_master = df[df["niveau"]=="Master"]["taille"].mean()
print("Taille moyenne Master :", taille_master)
# 1.65 m

# 7. Comparer âge vs Alice
df["plus_age_que_Alice"] = df["age"] > df.loc[df["nom"]=="Alice", "age"].values[0]
print(df[["nom", "plus_age_que_Alice"]])
# Alice False
# Bob True
# Charlie False
# Diana True

────────────────────────────────────────
EXPLICATIONS

- groupby("etudiant") -> calcule moyenne séparée pour True/False
- & dans filtre -> logique ET
- sort_values("niveau") -> utilise l’ordre défini dans la catégorie ordinale
- value_counts() -> fréquences de chaque catégorie
- dt.year -> extraction de l’année depuis datetime64
- Comparaison ordinale -> booléens True/False pour chaque ligne
- Toutes les étapes montrent comment exploiter les types de données Pandas correctement

────────────────────────────────────────
EXERCICE DIKW — DATAFRAME PYTHON / PANDAS
────────────────────────────────────────

Objectif :
- Visualiser la transformation des données brutes en information, connaissance et décision
- Appliquer le concept DIKW à un exemple concret

DataFrame de référence :

       nom  age  taille  etudiant    niveau   date_nais
0    Alice   25    1.65      True    Master  1999-03-15
1      Bob   30    1.80     False   Licence  1994-07-22
2   Charlie  22    1.72      True   Licence  2002-01-10
3     Diana   28    1.68     False  Doctorat  1996-11-05

────────────────────────────────────────
1. DATA (Données brutes)
────────────────────────────────────────

- Faits collectés, non traités
- Exemples de données dans le DataFrame :

  Alice, 25, 1.65, True, Master, 1999-03-15
  Bob, 30, 1.80, False, Licence, 1994-07-22
  Charlie, 22, 1.72, True, Licence, 2002-01-10
  Diana, 28, 1.68, False, Doctorat, 1996-11-05

-> Ces données sont brutes : elles existent mais n’indiquent pas de tendance ni de décision

────────────────────────────────────────
2. INFORMATION
────────────────────────────────────────

- On organise et contextualise les données
- Exemples :

  - Moyenne d’âge : (25+30+22+28)/4 = 26.25 ans
  - Nombre d’étudiants : 2 / 4 = 50%
  - Taille moyenne : (1.65+1.80+1.72+1.68)/4 ≈ 1.7125 m

- Résultat : on sait "ce qui se passe" dans le DataFrame

────────────────────────────────────────
3. KNOWLEDGE (Connaissance)
────────────────────────────────────────

- On détecte des tendances, patterns et relations
- Exemples :

  - Les étudiants sont plus jeunes (Alice 25, Charlie 22) -> pattern âge vs statut étudiant
  - Niveau "Licence" apparaît 2 fois -> tendance de distribution
  - Taille moyenne par niveau : Master = 1.65, Licence = 1.76, Doctorat = 1.68 -> insight sur la relation taille/niveau

- Résultat : on comprend "comment les données se comportent"

────────────────────────────────────────
4. WISDOM (Sagesse)
────────────────────────────────────────

- On prend une décision ou action basée sur la connaissance
- Exemples de décisions à partir du DataFrame :

  - Offrir des promotions ciblées aux étudiants (Alice et Charlie)
  - Adapter les produits selon le niveau ou âge moyen
  - Organiser des formations ou communications selon le niveau académique

- Résultat : action concrète guidée par la data

────────────────────────────────────────
5. SCHÉMA VISUEL DIKW

DATA (brut)         : Alice, 25, 1.65, True, Master, 1999-03-15
       v
INFORMATION (organisée) : Moyenne âge = 26.25, Étudiants = 50%, Taille moyenne = 1.71 m
       v
KNOWLEDGE (analyse)    : Les étudiants sont plus jeunes, tendance des niveaux, corrélation taille/niveau
       v
WISDOM (décision)     : Promotions ciblées, adaptation produits, communication personnalisée

────────────────────────────────────────
6. CONCLUSION PÉDAGOGIQUE

- Chaque niveau ajoute de la valeur :
  * DATA -> Faits bruts
  * INFORMATION -> Compréhension de base
  * KNOWLEDGE -> Modèles et patterns
  * WISDOM -> Décisions intelligentes

- Les élèves visualisent ainsi comment un DataFrame Pandas peut être exploité **de manière progressive** pour passer des simples données à la prise de décision stratégique.

────────────────────────────────────────
2.5 BONNES PRATIQUES PROFESSIONNELLES
────────────────────────────────────────

1. TOUJOURS vérifier les types après chargement d'un fichier
   df.dtypes  # affiche tous les types

2. CONVERTIR les dates dès le chargement
   pd.read_csv("data.csv", parse_dates=["date_col"])

3. UTILISER "category" pour les colonnes avec peu de valeurs distinctes
   -> Réduit la mémoire de 50% à 90%
   df["genre"] = df["genre"].astype("category")

4. DOCUMENTER les types attendus en commentaire
   # age : int64, positif, entre 0 et 120
   # revenu : float64, positif ou NaN si non renseigné

5. VÉRIFIER les incohérences de types
   # Une colonne "age" de type object = problème ! (contient du texte)

────────────────────────────────────────
2.6 ERREURS FRÉQUENTES
────────────────────────────────────────

ERREUR 1 : Calculer la moyenne sur une variable nominale
  Mauvais : df["couleur_yeux"].mean()  -> ERREUR ou résultat absurde
  Bon     : df["couleur_yeux"].value_counts()

ERREUR 2 : Ne pas convertir les dates
  Mauvais : "2024-01-15" + 7 jours -> IMPOSSIBLE sur un string
  Bon     : pd.to_datetime("2024-01-15") + pd.Timedelta(days=7)

ERREUR 3 : Confondre int et float
  Mauvais : age = 25.0  (float) alors que c'est un entier
  Bon     : age = 25    (int)

ERREUR 4 : Laisser une colonne "object" quand c'est une catégorie
  Mauvais : "genre" stocké en object -> gaspillage mémoire
  Bon     : "genre" stocké en category -> 10x moins de mémoire

================================================================================
CHAPITRE 3 — CYCLE DE VIE DES DONNÉES
================================================================================

────────────────────────────────────────
3.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────

Le cycle de vie des données (Data Lifecycle) décrit le parcours complet
d'une donnée depuis sa création jusqu'à sa suppression.

Pourquoi c’est important de comprendre ce cycle :
- Savoir à quelle étape intervenir en tant que data scientist
- Garantir la qualité et l'intégrité des données
- Respecter les obligations légales (RGPD)
- Optimiser les coûts de stockage et d’exploitation

Chaque phase joue un rôle critique pour transformer une donnée brute
en décision ou insight utile.

────────────────────────────────────────
3.2 LES 7 PHASES DU CYCLE DE VIE
────────────────────────────────────────

PHASE 1 : CRÉATION (Data Creation / Capture)
--------------------------------------------------
Comment les données naissent-elles ?

a) Capture directe :
   - Formulaires web
   - Capteurs IoT
   - Transactions financières

b) Collecte :
   - Web scraping
   - APIs
   - Import de fichiers CSV

c) Génération :
   - Logs systèmes
   - Données simulées ou synthétiques

d) Acquisition :
   - Achat de datasets tiers
   - Données open source

Exemples :
- Client remplit formulaire inscription -> données créées en BDD
- Capteur température usine envoie mesure toutes les secondes
- Serveur web log chaque requête HTTP automatiquement

PHASE 2 : STOCKAGE (Data Storage)
--------------------------------------------------
Où stocker les données ?

a) Fichiers plats : CSV, JSON, Parquet, HDF5  
b) Bases de données : SQL (PostgreSQL, MySQL), NoSQL (MongoDB, Redis)  
c) Data Warehouse : BigQuery, Snowflake, Redshift (grande échelle)  
d) Data Lake : stockage brut (AWS S3, Azure Blob)  
e) Cloud : Google Cloud, AWS, Azure  

Critères de choix :
- Volume : petit -> CSV/SQLite, grand -> BDD/Cloud  
- Fréquence d'accès : souvent -> BDD, rarement -> archive froide  
- Structure : structuré -> SQL, semi-structuré -> NoSQL  

PHASE 3 : TRAITEMENT (Data Processing)
--------------------------------------------------
Transformation des données brutes en données exploitables :

a) ETL (Extract, Transform, Load) : extraction, transformation, chargement  
b) ELT (Extract, Load, Transform) : variante moderne adaptée au Big Data  
c) Streaming : traitement en temps réel (Apache Kafka, Spark Streaming)  
d) Batch processing : traitement par lots (ex : chaque nuit)

Opérations typiques :
- Nettoyage (cleaning) : suppression des doublons, correction des erreurs
- Transformation : normalisation, encodage, création de nouvelles variables
- Agrégation : calcul de moyennes, sommes, regroupements

PHASE 4 : ANALYSE (Data Analysis)
--------------------------------------------------
Extraction d’insights utiles :

a) Analyse descriptive : Que s'est-il passé ? (statistiques de base)  
b) Analyse diagnostique : Pourquoi est-ce arrivé ? (corrélations, causalité)  
c) Analyse prédictive : Que va-t-il se passer ? (modèles ML, régression)  
d) Analyse prescriptive : Que doit-on faire ? (recommandations, actions)  

PHASE 5 : VISUALISATION (Data Visualization)
--------------------------------------------------
Communication claire des insights :

- Graphiques statiques : Matplotlib, Seaborn  
- Dashboards interactifs : Plotly, Dash, Power BI, Tableau  
- Rapports automatisés : Jupyter Notebooks, Streamlit  

PHASE 6 : PARTAGE & UTILISATION (Data Sharing)
--------------------------------------------------
Rendre les données utiles à d’autres :

- Rapports pour les parties prenantes  
- APIs pour d'autres applications  
- Publication open data  
- Intégration dans des modèles ML  

PHASE 7 : ARCHIVAGE & SUPPRESSION (Data Archival & Deletion)
--------------------------------------------------
Gestion du cycle final des données :

- Archivage des données historiques importantes  
- Suppression des données obsolètes  
- Respect RGPD : droit à l’oubli, anonymisation  
- Politique de rétention des données  

────────────────────────────────────────
[IDEE] CONSEIL PÉDAGOGIQUE
────────────────────────────────────────

- Chaque phase du cycle est interdépendante : négliger une étape peut fausser l’analyse.  
- Les outils et technologies diffèrent selon le volume, la structure et la fréquence d’utilisation des données.  
- Un Data Scientist doit savoir quand et comment intervenir à chaque étape pour garantir **fiabilité, sécurité et pertinence** des données.

────────────────────────────────────────
3.3 LE RÔLE DU DATA SCIENTIST DANS CE CYCLE
────────────────────────────────────────

Le Data Scientist intervient à chaque étape du cycle de vie des données
pour garantir que les données soient **fiables, exploitables et sécurisées**.
Voici le rôle détaillé par phase :

PHASE           | RÔLE DU DATA SCIENTIST
─────────────────────────────────────────────
Création        | - Identifier les données pertinentes à collecter
                | - Définir les sources (formulaires, capteurs, API)
                | - S’assurer de la qualité initiale et de la conformité RGPD

Stockage        | - Choisir le format de stockage optimal (CSV, SQL, NoSQL, Data Lake)
                | - Garantir la sécurité et l’accès aux données
                | - Planifier la scalabilité et l’archivage

Traitement      | - Écrire et maintenir les pipelines ETL/ELT
                | - Nettoyer les données (doublons, valeurs manquantes, outliers)
                | - Transformer les données pour l’analyse (normalisation, encodage)
                | - Effectuer des agrégations et calculs statistiques

Analyse         | - Réaliser l’EDA (Exploratory Data Analysis)
                | - Identifier patterns, corrélations et anomalies
                | - Construire et valider des modèles prédictifs ou descriptifs
                | - Fournir des insights exploitables pour le business

Visualisation   | - Créer des graphiques et visualisations clairs
                | - Développer des dashboards interactifs
                | - Communiquer les résultats aux parties prenantes

Partage         | - Rédiger des rapports compréhensibles
                | - Présenter les résultats lors de réunions
                | - Mettre à disposition les données via API ou publication open data

Archivage       | - Définir les politiques de rétention et d’archivage
                | - Supprimer les données obsolètes ou sensibles
                | - Assurer la conformité légale (RGPD, ISO, etc.)

[IDEE] Astuce pédagogique :

- Le Data Scientist ne se limite pas à l’analyse statistique ;  
  il intervient **du début à la fin du cycle de vie**, garantissant que chaque donnée collectée
  se transforme en décision éclairée.  
- Comprendre ce rôle permet de mieux organiser son travail et prioriser les tâches selon l’étape du cycle.

────────────────────────────────────────
3.4 IMPLÉMENTATION PRATIQUE : PIPELINE SIMPLE
────────────────────────────────────────

# ============================================================
# PIPELINE PRATIQUE — CYCLE DE VIE COMPLET DES DONNÉES
# ============================================================

import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import os

# ============================================================
# PHASE 1 : CRÉATION DE DONNÉES SIMULÉES
# ============================================================

# Objectif : générer un dataset fictif pour simuler un e-commerce
np.random.seed(42)  # Reproductibilité des résultats

n_records = 1000  # Nombre d'observations à créer

# Génération des données brutes
data_brut = {
    "id_commande": range(1001, 1001 + n_records),  # Identifiants uniques
    "date": [datetime(2024,1,1) + timedelta(days=np.random.randint(0,365))
             for _ in range(n_records)],           # Dates aléatoires
    "client_id": np.random.randint(1, 201, n_records),  # 200 clients distincts
    "produit": np.random.choice(
        ["Laptop", "Phone", "Tablet", "Watch", "Headphones"], n_records),
    "quantite": np.random.randint(1, 6, n_records),        # 1 à 5 produits
    "prix_unitaire": np.random.choice([299.99, 699.99, 449.99, 199.99, 99.99], n_records),
    "pays": np.random.choice(
        ["France", "Belgique", "Suisse", "Canada", None],
        n_records, p=[0.5,0.2,0.15,0.1,0.05])  # 5% de NaN
}

df_brut = pd.DataFrame(data_brut)
print("=== DONNÉES BRUTES ===")
print(df_brut.head())
print(f"Shape : {df_brut.shape}")

# ============================================================
# PHASE 2 : STOCKAGE INITIAL
# ============================================================

# Création des dossiers si inexistant
os.makedirs("data/raw", exist_ok=True)
os.makedirs("data/processed", exist_ok=True)

# Sauvegarde CSV
raw_file = "data/raw/ventes_brutes.csv"
df_brut.to_csv(raw_file, index=False)
print(f"Données brutes sauvegardées : {raw_file}")

# ============================================================
# PHASE 3 : TRAITEMENT / NETTOYAGE
# ============================================================

# Relecture pour simuler le cycle réel
df = pd.read_csv(raw_file)

# Conversion des types
df["date"] = pd.to_datetime(df["date"])

# Vérification des valeurs manquantes
print(f"\nValeurs manquantes avant traitement :\n{df.isnull().sum()}")

# Remplacer NaN
df["pays"] = df["pays"].fillna("Inconnu")

# Feature engineering
df["montant_total"] = df["quantite"] * df["prix_unitaire"]  # CA par commande
df["mois"] = df["date"].dt.month
df["trimestre"] = df["date"].dt.quarter
df["jour_semaine"] = df["date"].dt.day_name()

# Vérification après transformation
print("\nExtrait après traitement :")
print(df.head())

# Sauvegarde des données traitées
processed_file = "data/processed/ventes_traitees.csv"
df.to_csv(processed_file, index=False)
print(f"Données traitées sauvegardées : {processed_file}")

# ============================================================
# PHASE 4 : ANALYSE
# ============================================================

# Statistiques globales
ca_total = df["montant_total"].sum()
nb_commandes = len(df)
panier_moyen = df["montant_total"].mean()

print("\n=== ANALYSE DES VENTES ===")
print(f"Chiffre d'affaires total : {ca_total:,.2f} €")
print(f"Nombre de commandes     : {nb_commandes}")
print(f"Panier moyen            : {panier_moyen:.2f} €")

# CA par produit
ca_par_produit = df.groupby("produit")["montant_total"].sum().sort_values(ascending=False)
print("\nChiffre d'affaires par produit :")
print(ca_par_produit)

# CA par pays
ca_par_pays = df.groupby("pays")["montant_total"].sum()
print("\nChiffre d'affaires par pays :")
print(ca_par_pays)

# ============================================================
# PHASE 5 : VISUALISATION (simplifiée)
# ============================================================

print("\n=== VISUALISATION (texte) ===")
print(ca_par_produit.to_string())

# ============================================================
# PHASE 6 : RAPPORT AUTOMATISÉ
# ============================================================

rapport_file = "data/rapport_mensuel.txt"
with open(rapport_file, "w") as f:
    f.write("=== RAPPORT VENTES 2024 ===\n\n")
    f.write(f"Chiffre d'affaires total : {ca_total:,.2f} €\n")
    f.write(f"Nombre de commandes     : {nb_commandes}\n")
    f.write(f"Panier moyen            : {panier_moyen:.2f} €\n\n")
    f.write("Chiffre d'affaires par produit :\n")
    f.write(ca_par_produit.to_string())
    f.write("\n\nChiffre d'affaires par pays :\n")
    f.write(ca_par_pays.to_string())

print(f"\nRapport généré : {rapport_file}")

# ============================================================
# BONUS : CHECK QUALITÉ
# ============================================================

# Vérification doublons
nb_doublons = df.duplicated().sum()
print(f"\nNombre de doublons : {nb_doublons}")

# Vérification cohérence
if (df["quantite"] <= 0).any():
    print("Attention : Quantité négative détectée !")
else:
    print("Toutes les quantités sont cohérentes.")

# Fin du pipeline
print("\nPipeline terminé avec succès [OK]")

────────────────────────────────────────
3.5 EXERCICES PRATIQUES — CHAPITRE 3
────────────────────────────────────────

EXERCICES FACILES :
  1. Listez les 7 phases du cycle de vie et donnez 1 exemple concret
     pour chaque phase dans le contexte d'une banque

  2. Quelle est la différence entre un Data Lake et un Data Warehouse ?

  3. Qu'est-ce que l'ETL ? Donnez un exemple concret

EXERCICES INTERMÉDIAIRES :
  4. Un hôpital collecte des données de patients. Décrivez le cycle
     de vie complet avec les contraintes RGPD à chaque phase

  5. Comparez CSV vs Parquet comme format de stockage pour 10 millions
     de lignes de données financières

  6. Créez un pipeline Python minimal qui : lit un CSV, nettoie les NaN,
     et sauvegarde le résultat

EXERCICES AVANCÉS :
  7. Concevez une architecture complète pour un système de recommendation
     en temps réel (cycle de vie de la donnée)

  8. Implémentez un script Python qui automatise les phases 1, 2 et 3
     pour un dataset de votre choix

  9. Rédigez une politique de rétention des données pour une application
     mobile de fitness (quelles données, combien de temps, pourquoi)

================================================================================
CHAPITRE 4 — INTRODUCTION À PYTHON
================================================================================

────────────────────────────────────────
4.1 POURQUOI PYTHON POUR LA DATA ?
────────────────────────────────────────

Python est devenu LE langage de référence pour l'analyse de données.
Voici pourquoi :

  1. SIMPLICITÉ      : Syntaxe proche de l'anglais, facile à lire
  2. POLYVALENCE     : Data, Web, IA, Automation — tout en Python
  3. ÉCOSYSTÈME      : NumPy, Pandas, Sklearn, TensorFlow... milliers de libs
  4. COMMUNAUTÉ      : Millions de développeurs, Stack Overflow, GitHub
  5. PERFORMANCE     : NumPy et Pandas sont optimisés en C (très rapides)
  6. GRATUIT         : Open source, aucune licence
  7. REPRODUCTIBLE   : Jupyter Notebooks permettent de partager l'analyse

Comparaison avec alternatives :
  PYTHON vs R       : Python plus généraliste, R spécialisé en stats
  PYTHON vs SQL     : Python plus flexible, SQL meilleur pour les BDD
  PYTHON vs EXCEL   : Python gère des millions de lignes, Excel ~1 million max
  PYTHON vs MATLAB  : Python gratuit, MATLAB payant

────────────────────────────────────────
4.2 LES BASES ABSOLUES DE PYTHON (SYNTAXE)
────────────────────────────────────────

```python
# ============================================================
# LES FONDAMENTAUX PYTHON POUR LA DATA
# ============================================================

# ─── VARIABLES ────────────────────────────────────────────────
x = 10           # Affectation simple
y = 3.14         # Float
nom = "Python"   # String
actif = True     # Booléen
vide = None      # Valeur nulle (important pour les NaN)

# Python utilise l'indentation (4 espaces) — OBLIGATOIRE
# Pas d'accolades {} comme en Java ou C

# ─── OPÉRATIONS DE BASE ───────────────────────────────────────
print(10 + 3)    # 13  (addition)
print(10 - 3)    # 7   (soustraction)
print(10 * 3)    # 30  (multiplication)
print(10 / 3)    # 3.333... (division réelle)
print(10 // 3)   # 3   (division entière)
print(10 % 3)    # 1   (modulo = reste)
print(10 ** 3)   # 1000 (puissance)

# ─── CHAÎNES DE CARACTÈRES ────────────────────────────────────
prenom = "Alice"
nom = "Dupont"

# Concaténation
print(prenom + " " + nom)   # "Alice Dupont"

# f-strings (moderne, recommandé)
age = 25
print(f"{prenom} a {age} ans")  # "Alice a 25 ans"

# Méthodes utiles
texte = "  Bonjour le Monde  "
print(texte.strip())    # "Bonjour le Monde" (supprime espaces)
print(texte.lower())    # "  bonjour le monde  "
print(texte.upper())    # "  BONJOUR LE MONDE  "
print(texte.replace("Bonjour", "Salut"))  # "  Salut le Monde  "
print(texte.split(" "))  # ['', '', 'Bonjour', 'le', 'Monde', '', '']

# ─── LISTES ───────────────────────────────────────────────────
notes = [15, 18, 12, 9, 17, 14]

# Accès par index (commence à 0)
print(notes[0])    # 15 (premier)
print(notes[-1])   # 14 (dernier)
print(notes[1:4])  # [18, 12, 9] (slice : index 1 à 3 inclus)

# Méthodes
notes.append(16)          # Ajoute à la fin
notes.insert(0, 20)       # Insère à l'index 0
notes.remove(9)           # Supprime la valeur 9
notes.sort()              # Trie en ordre croissant
print(sorted(notes, reverse=True))  # Trie décroissant sans modifier

# List comprehension (TRÈS utilisé en data)
notes_doublees = [n * 2 for n in notes]  # [30, 24, 32, 28, 34, 36]
bonnes_notes = [n for n in notes if n >= 15]  # [15, 18, 17, 16]

# ─── DICTIONNAIRES ────────────────────────────────────────────
etudiant = {
    "nom": "Alice",
    "age": 25,
    "notes": [15, 18, 12],
    "actif": True
}

# Accès
print(etudiant["nom"])          # "Alice"
print(etudiant.get("email", "Non renseigné"))  # Valeur par défaut si clé manquante

# Modification
etudiant["age"] = 26            # Modification
etudiant["email"] = "alice@ex.com"  # Ajout

# Itération
for cle, valeur in etudiant.items():
    print(f"{cle} : {valeur}")

# ─── CONDITIONS ───────────────────────────────────────────────
age = 17

if age >= 18:
    print("Majeur")
elif age >= 16:
    print("Presque majeur")
else:
    print("Mineur")

# Opérateurs logiques
if age >= 16 and age < 18:
    print("Entre 16 et 17 ans")

if age < 13 or age > 60:
    print("Tarif réduit")

# ─── BOUCLES ──────────────────────────────────────────────────
# For avec range
for i in range(5):          # 0, 1, 2, 3, 4
    print(i)

for i in range(0, 10, 2):  # 0, 2, 4, 6, 8 (step=2)
    print(i)

# For sur une liste
fruits = ["pomme", "banane", "cerise"]
for fruit in fruits:
    print(fruit)

# Enumerate : index + valeur
for i, fruit in enumerate(fruits):
    print(f"{i}: {fruit}")

# While
compteur = 0
while compteur < 5:
    print(compteur)
    compteur += 1  # compteur = compteur + 1

# ─── FONCTIONS ────────────────────────────────────────────────
def calculer_moyenne(liste_notes):
    """
    Calcule la moyenne d'une liste de notes.

    Args:
        liste_notes (list): Liste de nombres

    Returns:
        float: Moyenne des notes
    """
    if len(liste_notes) == 0:  # Vérification anti-division par zéro
        return 0
    return sum(liste_notes) / len(liste_notes)

# Utilisation
notes = [15, 18, 12, 17]
moy = calculer_moyenne(notes)
print(f"Moyenne : {moy:.2f}")  # "Moyenne : 15.50"

# Fonction avec valeur par défaut
def saluer(nom, titre="Dr."):
    return f"Bonjour, {titre} {nom} !"

print(saluer("Alice"))         # "Bonjour, Dr. Alice !"
print(saluer("Bob", "M."))     # "Bonjour, M. Bob !"

# Lambda (fonction anonyme, 1 ligne)
carre = lambda x: x ** 2
print(carre(5))  # 25

# Map : appliquer une fonction à tous les éléments
notes = [15, 18, 12]
notes_sur_20 = list(map(lambda n: n / 20 * 100, notes))
print(notes_sur_20)  # [75.0, 90.0, 60.0]

# Filter : filtrer les éléments
bonnes = list(filter(lambda n: n >= 15, notes))
print(bonnes)  # [15, 18]
```

────────────────────────────────────────
4.3 GESTION DES ERREURS (EXCEPTIONS)
────────────────────────────────────────

```python
# ─── TRY / EXCEPT : ESSENTIEL EN DATA ─────────────────────────
# Les données réelles sont sales -> il FAUT gérer les erreurs

def diviser(a, b):
    """Division sûre avec gestion d'erreur."""
    try:
        resultat = a / b
        return resultat
    except ZeroDivisionError:
        print("Erreur : Division par zéro !")
        return None
    except TypeError as e:
        print(f"Erreur de type : {e}")
        return None
    finally:
        print("Opération terminée.")  # Exécuté TOUJOURS

print(diviser(10, 2))   # 5.0
print(diviser(10, 0))   # None (avec message d'erreur)
print(diviser(10, "a")) # None (avec message d'erreur)

# ─── ASSERT : VALIDATION DES DONNÉES ─────────────────────────
def valider_age(age):
    assert isinstance(age, int), f"L'âge doit être un entier, reçu : {type(age)}"
    assert 0 <= age <= 150, f"L'âge doit être entre 0 et 150, reçu : {age}"
    return True

try:
    valider_age(25)    # OK
    valider_age(-5)    # AssertionError : âge négatif
    valider_age("25")  # AssertionError : type incorrect
except AssertionError as e:
    print(f"Validation échouée : {e}")
```

────────────────────────────────────────
4.4 MODULES ET IMPORTS
────────────────────────────────────────

```python
# ─── IMPORTS ESSENTIELS EN DATA SCIENCE ───────────────────────

# Import standard (module complet)
import os           # Système de fichiers
import sys          # Système Python
import math         # Mathématiques
import datetime     # Dates et heures
import json         # JSON
import csv          # CSV natif
import re           # Expressions régulières

# Import avec alias (convention)
import numpy as np          # np est le standard universel
import pandas as pd         # pd est le standard universel
import matplotlib.pyplot as plt  # plt est le standard
import seaborn as sns        # sns est le standard

# Import sélectif (only what you need)
from datetime import datetime, timedelta
from pathlib import Path      # Gestion moderne des chemins
from collections import Counter, defaultdict
from typing import List, Dict, Optional  # Type hints (Python 3.5+)

# ─── EXEMPLE D'UTILISATION ────────────────────────────────────
import math

print(math.pi)      # 3.141592...
print(math.sqrt(16)) # 4.0
print(math.log(100, 10))  # 2.0  (log base 10 de 100)

# os : gestion des fichiers
import os
print(os.getcwd())   # Dossier courant
os.makedirs("mon_dossier", exist_ok=True)  # Créer dossier
fichiers = os.listdir(".")  # Lister les fichiers

# pathlib : alternative moderne à os
from pathlib import Path
p = Path("data/raw/fichier.csv")
print(p.name)     # "fichier.csv"
print(p.stem)     # "fichier" (sans extension)
print(p.suffix)   # ".csv"
print(p.parent)   # "data/raw"
```

────────────────────────────────────────
4.5 EXERCICES PRATIQUES — CHAPITRE 4
────────────────────────────────────────

EXERCICES FACILES :
  1. Créez une fonction qui prend une liste de nombres et retourne
     la somme, la moyenne, le min et le max

  2. Créez un dictionnaire représentant un étudiant et affichez
     chaque clé/valeur avec une boucle

  3. Utilisez une list comprehension pour extraire les nombres pairs
     de 1 à 50

EXERCICES INTERMÉDIAIRES :
  4. Créez une fonction qui lit un fichier CSV ligne par ligne
     avec le module csv natif de Python

  5. Implémentez une fonction de nettoyage de texte qui :
     - met en minuscules
     - supprime les espaces en début/fin
     - remplace les accents

  6. Gérez les exceptions dans une fonction qui convertit
     une chaîne en nombre flottant

EXERCICES AVANCÉS :
  7. Créez une classe Python "Dataset" avec des méthodes pour
     charger, afficher et décrire des données

  8. Implémentez un décorateur Python qui mesure le temps
     d'exécution d'une fonction

  9. Créez un pipeline fonctionnel avec map, filter, reduce
     pour analyser une liste de transactions financières

────────────────────────────────────────
4.6 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────

CORRIGÉ EXERCICE 1 :
```python
def statistiques_basiques(nombres):
    """
    Calcule les statistiques de base d'une liste.

    Args:
        nombres (list): Liste de nombres

    Returns:
        dict: Dictionnaire contenant somme, moyenne, min, max

    Raises:
        ValueError: Si la liste est vide
    """
    if not nombres:  # if len(nombres) == 0
        raise ValueError("La liste ne peut pas être vide")

    return {
        "somme":   sum(nombres),
        "moyenne": sum(nombres) / len(nombres),
        "minimum": min(nombres),
        "maximum": max(nombres),
        "count":   len(nombres)
    }

# Test
notes = [15, 18, 12, 9, 17, 14, 16]
stats = statistiques_basiques(notes)

for cle, valeur in stats.items():
    print(f"{cle:10} : {valeur}")
# somme      : 101
# moyenne    : 14.428571428571429
# minimum    : 9
# maximum    : 18
# count      : 7
```

CORRIGÉ EXERCICE 8 (Décorateur) :
```python
import time
import functools

def mesurer_temps(func):
    """Décorateur qui mesure le temps d'exécution d'une fonction."""
    @functools.wraps(func)  # Préserve le nom et la doc de la fonction
    def wrapper(*args, **kwargs):
        debut = time.perf_counter()     # Démarrer le chrono
        resultat = func(*args, **kwargs)  # Exécuter la fonction
        fin = time.perf_counter()       # Arrêter le chrono
        duree = fin - debut
        print(f"[TIMER] {func.__name__} : {duree:.4f}s")
        return resultat
    return wrapper

# Utilisation
@mesurer_temps
def traiter_donnees(n):
    """Simule un traitement long."""
    return sum(range(n))

resultat = traiter_donnees(1_000_000)
# [TIMER] traiter_donnees : 0.0421s
```

================================================================================
CHAPITRE 5 — INSTALLATION DE L'ENVIRONNEMENT
================================================================================

────────────────────────────────────────
5.1 INTRODUCTION : QUEL ENVIRONNEMENT CHOISIR ?
────────────────────────────────────────

En data science, on utilise plusieurs outils :

  1. Python         : Le langage de base
  2. pip/conda      : Gestionnaires de paquets
  3. venv/conda env : Environnements virtuels isolés
  4. Jupyter        : Notebooks interactifs
  5. VS Code / PyCharm : IDEs professionnels

Pourquoi des environnements virtuels ?
  - Isoler les dépendances de chaque projet
  - Éviter les conflits de versions (projet A besoin numpy 1.20,
    projet B besoin numpy 1.24)
  - Reproductibilité (requirements.txt = recette exacte)

────────────────────────────────────────
5.2 INSTALLATION ÉTAPE PAR ÉTAPE
────────────────────────────────────────

OPTION A : PYTHON + PIP + VENV (Recommandé pour débutants)

ÉTAPE 1 : Installer Python
  Windows :
    -> Aller sur https://python.org/downloads/
    -> Télécharger Python 3.11+ (version stable recommandée)
    -> Cocher "Add Python to PATH" lors de l'installation
    -> Vérifier : ouvrir CMD -> taper "python --version"

  macOS :
    -> Installer Homebrew : /bin/bash -c "$(curl -fsSL https://brew.sh)"
    -> brew install python3
    -> Vérifier : python3 --version

  Linux (Ubuntu/Debian) :
    -> sudo apt update
    -> sudo apt install python3 python3-pip python3-venv

ÉTAPE 2 : Créer un environnement virtuel
  # Windows
  python -m venv data_env

  # macOS / Linux
  python3 -m venv data_env

ÉTAPE 3 : Activer l'environnement
  # Windows (CMD)
  data_env\Scripts\activate

  # Windows (PowerShell)
  data_env\Scripts\Activate.ps1

  # macOS / Linux
  source data_env/bin/activate

  # Vous verrez : (data_env) $ -> indique que l'env est actif

ÉTAPE 4 : Installer les bibliothèques essentielles
  pip install numpy pandas matplotlib seaborn jupyter scikit-learn

ÉTAPE 5 : Sauvegarder les dépendances
  pip freeze > requirements.txt
  # Crée un fichier listant toutes les versions installées

ÉTAPE 6 : Restaurer l'environnement (sur une autre machine)
  pip install -r requirements.txt

OPTION B : ANACONDA (Recommandé pour débutants absolus)
  -> Télécharger Anaconda sur https://anaconda.com
  -> Inclut Python + 250 packages data science + Jupyter
  -> Interface graphique Anaconda Navigator disponible

  # Créer un environnement conda
  conda create --name data_env python=3.11
  conda activate data_env
  conda install numpy pandas matplotlib seaborn jupyter scikit-learn

────────────────────────────────────────
5.3 JUPYTER NOTEBOOK : GUIDE COMPLET
────────────────────────────────────────

Jupyter Notebook est l'outil principal des data scientists.
C'est un document interactif mêlant code, résultats et texte.

DÉMARRAGE :
  # Dans votre environnement activé
  jupyter notebook
  # Ouvre automatiquement dans votre navigateur : http://localhost:8888

JUPYTER LAB (version moderne de Jupyter) :
  pip install jupyterlab
  jupyter lab

STRUCTURE D'UN NOTEBOOK :
  - Cellule Code   : contient du Python, s'exécute avec Shift+Enter
  - Cellule Markdown : contient du texte formaté
  - Cellule Raw    : texte brut, non exécuté

RACCOURCIS CLAVIER ESSENTIELS :
  Shift + Enter    -> Exécuter la cellule et passer à la suivante
  Ctrl + Enter     -> Exécuter la cellule (rester dans la cellule)
  A                -> Insérer cellule au-dessus
  B                -> Insérer cellule en-dessous
  DD               -> Supprimer la cellule
  M                -> Convertir en Markdown
  Y                -> Convertir en Code
  Ctrl + Z         -> Annuler
  Ctrl + Shift + -  -> Couper la cellule

BONNE STRUCTURE D'UN NOTEBOOK PROFESSIONNEL :
```python
# ─── CELLULE 1 : EN-TÊTE ET IMPORTS ──────────────────────────
"""
# Analyse des Ventes 2024
**Auteur** : Alice Dupont
**Date**   : 2024-01-15
**Objectif** : Analyser les tendances de ventes

## Plan
1. Chargement des données
2. Exploration (EDA)
3. Visualisations
4. Conclusions
"""

# Imports (toujours en première cellule code)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from pathlib import Path

# Configuration globale
pd.set_option("display.max_columns", 50)   # Afficher jusqu'à 50 colonnes
pd.set_option("display.max_rows", 100)      # Afficher jusqu'à 100 lignes
pd.set_option("display.float_format", "{:.2f}".format)  # 2 décimales

plt.rcParams["figure.figsize"] = (12, 6)   # Taille par défaut des graphiques
plt.rcParams["figure.dpi"] = 100            # Résolution

print("Imports OK [OK]")
print(f"NumPy version  : {np.__version__}")
print(f"Pandas version : {pd.__version__}")
```

────────────────────────────────────────
5.4 VS CODE POUR LA DATA SCIENCE
────────────────────────────────────────

VS Code est l'IDE recommandé pour les projets de data science sérieux.

INSTALLATION :
  1. Télécharger VS Code : https://code.visualstudio.com
  2. Installer les extensions :
     - Python (Microsoft) — OBLIGATOIRE
     - Jupyter (Microsoft) — OBLIGATOIRE
     - Pylance — autocomplétion avancée
     - GitLens — intégration Git
     - indent-rainbow — visualiser l'indentation

CONFIGURATION RECOMMANDÉE (settings.json) :
```json
{
    "python.defaultInterpreterPath": "./data_env/bin/python",
    "editor.fontSize": 14,
    "editor.tabSize": 4,
    "editor.formatOnSave": true,
    "python.linting.enabled": true,
    "python.linting.flake8Enabled": true,
    "files.autoSave": "onFocusChange"
}
```

────────────────────────────────────────
5.5 STRUCTURE DE PROJET PROFESSIONNELLE
────────────────────────────────────────

Une structure de projet bien organisée est cruciale :

mon_projet_data/
├── data/
│   ├── raw/           # Données brutes originales (ne jamais modifier)
│   ├── processed/     # Données nettoyées
│   └── external/      # Données tierces
├── notebooks/
│   ├── 01_exploration.ipynb
│   ├── 02_nettoyage.ipynb
│   ├── 03_analyse.ipynb
│   └── 04_visualisation.ipynb
├── src/
│   ├── __init__.py
│   ├── data_loader.py    # Fonctions de chargement
│   ├── preprocessing.py  # Fonctions de nettoyage
│   ├── analysis.py       # Fonctions d'analyse
│   └── visualization.py  # Fonctions de visualisation
├── tests/
│   ├── test_data_loader.py
│   └── test_preprocessing.py
├── reports/
│   ├── figures/          # Graphiques sauvegardés
│   └── rapport_final.pdf
├── requirements.txt      # Dépendances Python
├── README.md             # Documentation du projet
└── .gitignore            # Fichiers à ignorer par Git

FICHIER .gitignore POUR UN PROJET DATA :
```
# Environnement Python
data_env/
__pycache__/
*.pyc
*.egg-info/

# Données sensibles (ne jamais commit en Git)
data/raw/
data/processed/
*.csv
*.xlsx
*.json

# Jupyter checkpoints
.ipynb_checkpoints/

# Fichiers système
.DS_Store
Thumbs.db

# Variables d'environnement
.env
```

────────────────────────────────────────
5.6 FICHIER REQUIREMENTS.TXT TYPE DATA SCIENCE
────────────────────────────────────────

```
# CORE DATA SCIENCE
numpy==1.26.2
pandas==2.1.3
scipy==1.11.4

# VISUALISATION
matplotlib==3.8.2
seaborn==0.13.0
plotly==5.18.0

# MACHINE LEARNING
scikit-learn==1.3.2

# NOTEBOOKS
jupyter==1.0.0
jupyterlab==4.0.8
ipywidgets==8.1.1

# UTILITAIRES
python-dotenv==1.0.0   # Variables d'environnement
tqdm==4.66.1           # Barres de progression
loguru==0.7.2          # Logging avancé
```

────────────────────────────────────────
5.7 VALIDATION DE L'INSTALLATION
────────────────────────────────────────

```python
# ============================================================
# SCRIPT DE VALIDATION DE L'ENVIRONNEMENT
# Exécutez ce script pour vérifier que tout est installé
# ============================================================

def check_import(module_name, alias=None):
    """Vérifie qu'un module est importable."""
    try:
        mod = __import__(module_name)
        version = getattr(mod, "__version__", "version non disponible")
        print(f"  [OK] {module_name:<20} version {version}")
        return True
    except ImportError:
        print(f"  [X] {module_name:<20} MANQUANT — pip install {module_name}")
        return False

print("=" * 50)
print("VALIDATION DE L'ENVIRONNEMENT DATA SCIENCE")
print("=" * 50)

modules = [
    "numpy", "pandas", "matplotlib",
    "seaborn", "sklearn", "scipy", "jupyter"
]

tous_ok = all(check_import(m) for m in modules)

print("=" * 50)
if tous_ok:
    print("[OK] Environnement complet — Prêt pour la data science !")
else:
    print("[X] Des modules manquent — Exécutez pip install -r requirements.txt")
```

────────────────────────────────────────
5.8 EXERCICES PRATIQUES — CHAPITRE 5
────────────────────────────────────────

EXERCICES FACILES :
  1. Installez Python et créez votre premier environnement virtuel
  2. Installez Jupyter et ouvrez votre premier notebook
  3. Créez la structure de dossiers recommandée pour un projet

EXERCICES INTERMÉDIAIRES :
  4. Créez un requirements.txt et essayez de reproduire votre
     environnement dans un nouveau venv

  5. Configurez VS Code avec les extensions recommandées et
     connectez-le à votre environnement virtuel

  6. Créez un notebook avec la structure professionnelle recommandée

EXERCICES AVANCÉS :
  7. Créez un Makefile pour automatiser la création d'environnement
     (make setup, make clean, make test)

  8. Configurez Git pour votre projet avec un .gitignore approprié
     et faites votre premier commit

  9. Créez un script de validation d'environnement qui vérifie
     les versions minimales requises et affiche un rapport

================================================================================
RÉSUMÉ DE LA PARTIE 1
================================================================================

Dans cette partie, nous avons couvert :

  [OK] La définition de la data et la pyramide DIKW
  [OK] Les types de données (quantitatif, qualitatif, nominal, ordinal, etc.)
  [OK] Le cycle de vie complet des données (7 phases)
  [OK] Les fondamentaux Python (variables, listes, dicts, fonctions, boucles)
  [OK] L'installation d'un environnement professionnel
  [OK] Jupyter Notebook et VS Code
  [OK] La structure de projet professionnelle

PROCHAINE ÉTAPE :
  Partie 2 — Python avancé pour la data
  (structures de données avancées, manipulation de fichiers, modules spécialisés)

================================================================================
FIN DE LA PARTIE 1
================================================================================

================================================================================
GUIDE COMPLET D'ANALYSE DE DONNÉES AVEC PYTHON
PARTIE 2 — PYTHON AVANCÉ POUR LA DATA
================================================================================
Chapitres : 6-10 | Variables avancées, Structures de données, Fonctions,
            Modules, Manipulation de fichiers
================================================================================

TABLE DES MATIÈRES — PARTIE 2
──────────────────────────────
Chapitre 6  : Variables et types avancés
Chapitre 7  : Structures de données avancées
Chapitre 8  : Fonctions avancées
Chapitre 9  : Modules et packages essentiels
Chapitre 10 : Manipulation de fichiers (CSV, JSON, Excel)

================================================================================
CHAPITRE 6 — VARIABLES ET TYPES AVANCÉS
================================================================================

OBJECTIF DU CHAPITRE

Comprendre en profondeur :
- Le comportement des objets en mémoire (mutable vs immuable)
- La sécurisation du code avec les type hints
- La gestion professionnelle des variables sensibles (.env)

Ces notions sont fondamentales pour écrire du code fiable,
maintenable et sécurisé en data science.

────────────────────────────────────────
6.1 MUTABILITÉ VS IMMUTABILITÉ
────────────────────────────────────────

En Python, une variable ne contient pas directement une valeur,
mais une RÉFÉRENCE vers un objet en mémoire.

La notion clé :
- IMMUTABLE -> l'objet ne peut pas changer
- MUTABLE -> l'objet peut être modifié après création

TYPES IMMUABLES (ne peuvent pas être modifiés après création) :
  - int, float, str, bool, tuple, frozenset

TYPES MUTABLES (peuvent être modifiés après création) :
  - list, dict, set, numpy array, pandas DataFrame

```python
# ─── EXEMPLE : MUTABILITÉ ────────────────────────────────────
# Types immuables : int, str, tuple
x = 10
y = x        # y pointe vers la MÊME valeur
x = 20       # x pointe maintenant vers une NOUVELLE valeur
print(y)     # 10 — y n'a pas changé

texte = "Python"
texte_upper = texte.upper()  # Crée un NOUVEAU string
print(texte)       # "Python" — original intact
print(texte_upper) # "PYTHON"

# Types mutables : list
liste_a = [1, 2, 3]
liste_b = liste_a         # liste_b pointe vers le MÊME objet !
liste_a.append(4)
print(liste_b)            # [1, 2, 3, 4] — MODIFIÉ car même objet

# Solution : copier explicitement
liste_c = liste_a.copy()  # ou list(liste_a) ou liste_a[:]
liste_a.append(5)
print(liste_c)            # [1, 2, 3, 4] — INCHANGÉ

# ─── IMPLICATIONS EN DATA SCIENCE ────────────────────────────
import pandas as pd

# ATTENTION : ceci est un PROBLÈME FRÉQUENT avec Pandas
df_original = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]})
df_copie = df_original        # Ce n'est PAS une vraie copie !
df_copie["C"] = [7, 8, 9]   # Modifie aussi df_original !

# SOLUTION CORRECTE
df_copie_safe = df_original.copy()  # Copie indépendante (deep copy)
df_copie_safe["C"] = [7, 8, 9]     # N'affecte PAS df_original
```

────────────────────────────────────────
6.2 TYPE HINTS (ANNOTATIONS DE TYPES)
────────────────────────────────────────

Les type hints documentent et sécurisent le code :

```python
from typing import List, Dict, Tuple, Optional, Union
import pandas as pd
import numpy as np

# Fonctions avec type hints
def calculer_stats(
    donnees: List[float],
    arrondi: int = 2
) -> Dict[str, float]:
    """
    Calcule les statistiques d'une liste de nombres.

    Args:
        donnees: Liste de valeurs numériques
        arrondi: Nombre de décimales pour l'arrondi

    Returns:
        Dictionnaire avec statistiques calculées
    """
    return {
        "moyenne":  round(sum(donnees) / len(donnees), arrondi),
        "minimum":  round(min(donnees), arrondi),
        "maximum":  round(max(donnees), arrondi),
    }

# Optional : le paramètre peut être None
def charger_dataset(
    chemin: str,
    nrows: Optional[int] = None  # None = charger tout le fichier
) -> pd.DataFrame:
    return pd.read_csv(chemin, nrows=nrows)

# Union : accepte plusieurs types
def formater_valeur(valeur: Union[int, float, str]) -> str:
    return str(valeur)
```

────────────────────────────────────────
6.3 VARIABLES D'ENVIRONNEMENT ET .ENV
────────────────────────────────────────

Ne jamais hardcoder les mots de passe et clés API dans le code :

```python
# ─── UTILISATION DU FICHIER .env ─────────────────────────────
# pip install python-dotenv

from dotenv import load_dotenv
import os

load_dotenv()  # Charge le fichier .env dans les variables d'environnement

# Accès aux variables
DB_HOST     = os.getenv("DB_HOST", "localhost")    # Valeur par défaut
DB_PASSWORD = os.getenv("DB_PASSWORD")             # Obligatoire
API_KEY     = os.getenv("OPENAI_API_KEY")

# Fichier .env (NE PAS committer en Git) :
# DB_HOST=192.168.1.100
# DB_PASSWORD=mon_super_mdp
# OPENAI_API_KEY=sk-xxxxxxxxxxxxx

print(f"Connexion à {DB_HOST}")  # Affiche l'hôte, PAS le mot de passe
```

================================================================================
CHAPITRE 7 — STRUCTURES DE DONNÉES AVANCÉES
================================================================================

────────────────────────────────────────
7.1 LISTES AVANCÉES
────────────────────────────────────────

```python
# ─── LIST COMPREHENSIONS AVANCÉES ────────────────────────────
# Syntaxe : [expression for item in iterable if condition]

# Simple
carres = [x**2 for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# Avec condition
pairs_carres = [x**2 for x in range(10) if x % 2 == 0]
# [0, 4, 16, 36, 64]

# Nested (2D -> 1D)
matrice = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
aplatie = [val for ligne in matrice for val in ligne]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Transformation conditionnelle
notes = [15, 8, 17, 11, 14, 6, 18]
mentions = ["Reçu" if n >= 10 else "Ajourné" for n in notes]
# ['Reçu', 'Ajourné', 'Reçu', 'Reçu', 'Reçu', 'Ajourné', 'Reçu']

# ─── OPÉRATIONS SUR LES LISTES ────────────────────────────────
from functools import reduce

nombres = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]

# Dédupliquer en préservant l'ordre
vus = set()
unique = [x for x in nombres if not (x in vus or vus.add(x))]
# [3, 1, 4, 5, 9, 2, 6]

# Regrouper par n
def grouper(liste, n):
    return [liste[i:i+n] for i in range(0, len(liste), n)]

groupes = grouper([1,2,3,4,5,6,7], 3)
# [[1, 2, 3], [4, 5, 6], [7]]

# Produit de tous les éléments
produit = reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])
# 120

# ─── SORTED AVEC KEY FUNCTION ─────────────────────────────────
etudiants = [
    {"nom": "Alice", "moyenne": 15.5},
    {"nom": "Bob",   "moyenne": 12.0},
    {"nom": "Carol", "moyenne": 18.0},
]

# Trier par moyenne décroissante
tries = sorted(etudiants, key=lambda e: e["moyenne"], reverse=True)
# Carol (18.0), Alice (15.5), Bob (12.0)

# Trier par plusieurs critères
donnees = [(1, "z"), (1, "a"), (2, "m")]
multi_tries = sorted(donnees, key=lambda x: (x[0], x[1]))
# [(1, 'a'), (1, 'z'), (2, 'm')]
```

────────────────────────────────────────
7.2 DICTIONNAIRES AVANCÉS
────────────────────────────────────────

```python
from collections import defaultdict, Counter, OrderedDict
from typing import Any

# ─── DICT COMPREHENSIONS ──────────────────────────────────────
# Créer un dict depuis deux listes
cles   = ["a", "b", "c"]
valeurs = [1, 2, 3]
dict_zip = {k: v for k, v in zip(cles, valeurs)}
# {'a': 1, 'b': 2, 'c': 3}

# Inverser un dictionnaire
original = {"a": 1, "b": 2, "c": 3}
inverse = {v: k for k, v in original.items()}
# {1: 'a', 2: 'b', 3: 'c'}

# Filtrer un dictionnaire
scores = {"Alice": 85, "Bob": 42, "Carol": 91, "Dave": 38}
admis = {nom: score for nom, score in scores.items() if score >= 50}
# {'Alice': 85, 'Carol': 91}

# ─── DEFAULTDICT : CLÉS AUTOMATIQUES ─────────────────────────
# Problème avec dict normal :
mots_par_lettre = {}
mots = ["apple", "avocado", "banana", "blueberry", "cherry"]
for mot in mots:
    lettre = mot[0]
    if lettre not in mots_par_lettre:  # Vérification nécessaire !
        mots_par_lettre[lettre] = []
    mots_par_lettre[lettre].append(mot)

# Solution avec defaultdict :
mots_par_lettre = defaultdict(list)  # list() appelé automatiquement
for mot in mots:
    mots_par_lettre[mot[0]].append(mot)  # Pas besoin de vérifier !

print(dict(mots_par_lettre))
# {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry'], 'c': ['cherry']}

# defaultdict pour compter
compteur = defaultdict(int)  # int() = 0 par défaut
texte = "mississippi"
for lettre in texte:
    compteur[lettre] += 1
print(dict(compteur))
# {'m': 1, 'i': 4, 's': 4, 'p': 2}

# ─── COUNTER : COMPTAGE AUTOMATIQUE ──────────────────────────
from collections import Counter

# Compter des éléments
votes = ["Alice", "Bob", "Alice", "Carol", "Alice", "Bob"]
compteur_votes = Counter(votes)
# Counter({'Alice': 3, 'Bob': 2, 'Carol': 1})

print(compteur_votes.most_common(2))  # Les 2 plus fréquents
# [('Alice', 3), ('Bob', 2)]

# Compter les caractères d'un texte
texte = "data science python"
freq = Counter(texte.replace(" ", ""))
# Counter({'t': 3, 'a': 3, 'e': 3, ...})

# Opérations entre Counter
c1 = Counter("hello")
c2 = Counter("world")
print(c1 + c2)  # Union (additionne les comptes)
print(c1 & c2)  # Intersection (minimum)

# ─── FUSION DE DICTIONNAIRES (PYTHON 3.9+) ────────────────────
base = {"a": 1, "b": 2}
extra = {"b": 99, "c": 3}

# Méthode 1 : opérateur | (Python 3.9+)
fusionné = base | extra
# {'a': 1, 'b': 99, 'c': 3}  <- extra écrase base pour "b"

# Méthode 2 : dict.update()
result = dict(base)  # Copie
result.update(extra)

# Méthode 3 : ** unpacking (Python 3.5+)
fusionné = {**base, **extra}

# ─── NESTED DICTS : SAFE ACCESS ───────────────────────────────
config = {
    "database": {
        "host": "localhost",
        "port": 5432,
        "credentials": {
            "username": "admin"
        }
    }
}

# Accès sécurisé (évite KeyError)
host = config.get("database", {}).get("host", "localhost")
password = config.get("database", {}).get("credentials", {}).get("password")
print(password)  # None (pas de KeyError)
```

────────────────────────────────────────
7.3 TUPLES ET NAMED TUPLES
────────────────────────────────────────

```python
from collections import namedtuple
from typing import NamedTuple

# ─── TUPLE BASIQUE ────────────────────────────────────────────
# Immuable, plus rapide que liste, utilisé pour regrouper des valeurs
point = (3.5, 7.2)     # Coordonnées (x, y)
rgb = (255, 128, 0)    # Couleur orange

# Unpacking
x, y = point
print(f"x={x}, y={y}")

# Swap de variables (unique à Python)
a, b = 10, 20
a, b = b, a  # Échange a et b
print(a, b)  # 20 10

# ─── NAMEDTUPLE : TUPLE AVEC NOMS ─────────────────────────────
# Plus lisible que tuple[0], tuple[1]...
Point = namedtuple("Point", ["x", "y"])
p = Point(3.5, 7.2)

print(p.x)     # 3.5  (accès par nom)
print(p[0])    # 3.5  (accès par index — aussi possible)
print(p)       # Point(x=3.5, y=7.2)

# Utilisation avec Pandas
Statistiques = namedtuple("Statistiques", ["mean", "std", "min", "max"])

def calculer_stats_tuple(serie):
    return Statistiques(
        mean=serie.mean(),
        std=serie.std(),
        min=serie.min(),
        max=serie.max()
    )

import pandas as pd
import numpy as np

ventes = pd.Series([100, 250, 80, 320, 150, 200])
stats = calculer_stats_tuple(ventes)
print(f"Moyenne : {stats.mean:.2f}, Écart-type : {stats.std:.2f}")
```

────────────────────────────────────────
7.4 ENSEMBLES (SETS)
────────────────────────────────────────

```python
# ─── SETS : COLLECTIONS SANS DOUBLONS ────────────────────────
# Ultra-rapides pour les tests d'appartenance : O(1) vs O(n) pour liste

pays_a = {"France", "Espagne", "Italie", "Portugal"}
pays_b = {"Espagne", "Portugal", "Grèce", "Turquie"}

# Opérations ensemblistes
print(pays_a | pays_b)    # Union : tous les pays
print(pays_a & pays_b)    # Intersection : pays communs
print(pays_a - pays_b)    # Différence : dans A mais pas B
print(pays_a ^ pays_b)    # Différence symétrique : dans l'un OU l'autre

# Test d'appartenance (très rapide)
print("France" in pays_a)      # True  — O(1)
print("Allemagne" in pays_a)   # False — O(1)

# Dédupliquer une liste avec set
valeurs = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
uniques = list(set(valeurs))   # [1, 2, 3, 4] — ordre non garanti
# Pour préserver l'ordre :
uniques_ordres = list(dict.fromkeys(valeurs))  # [1, 2, 3, 4]

# ─── APPLICATION DATA SCIENCE : COLONNES COMMUNES ─────────────
df1_cols = {"id", "nom", "age", "email"}
df2_cols = {"id", "nom", "score", "date"}

communes = df1_cols & df2_cols   # {'id', 'nom'}
manquantes_df2 = df1_cols - df2_cols  # {'age', 'email'}

print(f"Colonnes communes  : {communes}")
print(f"Dans df1 pas df2   : {manquantes_df2}")
```

────────────────────────────────────────
7.5 GÉNÉRATEURS ET ITÉRATEURS
────────────────────────────────────────

```python
# ─── GÉNÉRATEURS : TRAITEMENT MÉMOIRE-EFFICACE ───────────────
# Pour les TRÈS GROS datasets (des millions de lignes)

# Problème : list comprehension charge TOUT en mémoire
# Mauvais pour 10 millions de lignes :
# tous_carres = [x**2 for x in range(10_000_000)]  # ~400 MB RAM !

# Solution : générateur (calcule à la demande)
def generateur_carres(n):
    for x in range(n):
        yield x ** 2  # yield = "je renvoie ce résultat, puis je m'arrête"

gen = generateur_carres(10_000_000)  # Presque 0 RAM !
print(next(gen))   # 0
print(next(gen))   # 1
print(next(gen))   # 4

# Generator expression (comme list comp mais avec ())
somme_carres = sum(x**2 for x in range(1000))  # Efficace en mémoire

# ─── LECTURE LIGNE PAR LIGNE DE GROS FICHIERS ─────────────────
def lire_csv_par_chunks(chemin, chunk_size=10000):
    """Lit un grand CSV par morceaux pour économiser la RAM."""
    import pandas as pd
    for chunk in pd.read_csv(chemin, chunksize=chunk_size):
        yield chunk

# Utilisation
# for chunk in lire_csv_par_chunks("enormes_donnees.csv"):
#     # Traiter chaque chunk de 10 000 lignes
#     resultats = traiter(chunk)

# ─── ITERTOOLS : OUTILS D'ITÉRATION ──────────────────────────
import itertools

# product : produit cartésien
couleurs = ["rouge", "bleu"]
tailles = ["S", "M", "L"]
combinaisons = list(itertools.product(couleurs, tailles))
# [('rouge', 'S'), ('rouge', 'M'), ('rouge', 'L'),
#  ('bleu', 'S'),  ('bleu', 'M'),  ('bleu', 'L')]

# combinations : combinaisons sans répétition
equipe = ["A", "B", "C", "D"]
paires = list(itertools.combinations(equipe, 2))
# [('A','B'), ('A','C'), ('A','D'), ('B','C'), ('B','D'), ('C','D')]

# chain : concaténer des itérables
liste1 = [1, 2, 3]
liste2 = [4, 5, 6]
combinee = list(itertools.chain(liste1, liste2))
# [1, 2, 3, 4, 5, 6]

# groupby : grouper par clé
import operator
donnees = [
    {"dept": "Ventes", "emp": "Alice"},
    {"dept": "IT",     "emp": "Bob"},
    {"dept": "Ventes", "emp": "Carol"},
    {"dept": "IT",     "emp": "Dave"},
]
donnees_triees = sorted(donnees, key=operator.itemgetter("dept"))
for dept, groupe in itertools.groupby(donnees_triees, key=operator.itemgetter("dept")):
    employes = [g["emp"] for g in groupe]
    print(f"{dept}: {employes}")
# IT: ['Bob', 'Dave']
# Ventes: ['Alice', 'Carol']
```

================================================================================
CHAPITRE 8 — FONCTIONS AVANCÉES
================================================================================

────────────────────────────────────────
8.1 FONCTIONS D'ORDRE SUPÉRIEUR
────────────────────────────────────────

```python
# ─── MAP : TRANSFORMER CHAQUE ÉLÉMENT ────────────────────────
temperatures_celsius = [0, 20, 37, 100, -40]

# Convertir Celsius -> Fahrenheit
to_fahrenheit = lambda c : c * 9/5 + 32
temperatures_f = list(map(to_fahrenheit, temperatures_celsius))
# [32.0, 68.0, 98.6, 212.0, -40.0]

# Avec plusieurs itérables
longueurs = [3, 4, 5]
largeurs  = [4, 3, 6]
aires = list(map(lambda l, w : l * w, longueurs, largeurs))
# [12, 12, 30]

# ─── FILTER : SÉLECTIONNER DES ÉLÉMENTS ──────────────────────
donnees = [None, 1, "", 2, False, 3, [], 4, 0, 5]
valides = list(filter(None, donnees))  # Supprime les falsy values
# [1, 2, 3, 4, 5]

transactions = [-50, 100, -30, 200, -10, 150, -75]
credits  = list(filter(lambda x: x > 0, transactions))   # [100, 200, 150]
debits   = list(filter(lambda x: x < 0, transactions))   # [-50, -30, -10, -75]

# ─── REDUCE : ACCUMULER EN UNE VALEUR ─────────────────────────
from functools import reduce

nombres = [1, 2, 3, 4, 5]
produit = reduce(lambda acc, x: acc * x, nombres)  # 1*2*3*4*5 = 120

# Équivalent de sum() avec reduce
somme = reduce(lambda acc, x: acc + x, nombres, 0)  # 15

# Trouver le maximum
maximum = reduce(lambda a, b: a if a > b else b, nombres)  # 5
```

────────────────────────────────────────
8.2 DÉCORATEURS
────────────────────────────────────────

```python
import time
import functools
import logging

# ─── DÉCORATEUR DE BASE ───────────────────────────────────────
def mon_decorateur(func):
    @functools.wraps(func)  # Préserve le __name__ et __doc__
    def wrapper(*args, **kwargs):
        print(f"Avant l'appel de {func.__name__}")
        resultat = func(*args, **kwargs)
        print(f"Après l'appel de {func.__name__}")
        return resultat
    return wrapper

@mon_decorateur
def dire_bonjour(nom):
    print(f"Bonjour, {nom} !")

dire_bonjour("Alice")
# Avant l'appel de dire_bonjour
# Bonjour, Alice !
# Après l'appel de dire_bonjour

# ─── DÉCORATEURS UTILES EN DATA SCIENCE ──────────────────────

# 1. Mesure du temps d'exécution
def timeit(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[PERF] {func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

# 2. Validation des types
def valider_types(**types_attendus):
    def decorateur(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for param, type_attendu in types_attendus.items():
                if param in kwargs:
                    if not isinstance(kwargs[param], type_attendu):
                        raise TypeError(
                            f"{param} doit être {type_attendu.__name__}, "
                            f"reçu {type(kwargs[param]).__name__}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorateur

# 3. Logging automatique
def log_appel(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Appel : {func.__name__}({args}, {kwargs})")
        try:
            result = func(*args, **kwargs)
            logging.info(f"Succès : {func.__name__} -> {result}")
            return result
        except Exception as e:
            logging.error(f"Erreur dans {func.__name__}: {e}")
            raise
    return wrapper

# 4. Mise en cache (memoization)
from functools import lru_cache

@lru_cache(maxsize=128)  # Cache les 128 derniers résultats
def fibonacci(n):
    """Fibonacci avec memoization (TRÈS rapide)."""
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(100))  # Instantané grâce au cache

# Combinaison de décorateurs
@timeit
@log_appel
def analyser_dataset(df, colonnes):
    return df[colonnes].describe()
```

────────────────────────────────────────
8.3 CLASSES PYTHON POUR LA DATA SCIENCE
────────────────────────────────────────

```python
import pandas as pd
import numpy as np
from typing import List, Optional, Dict

class DatasetAnalyzer:
    """
    Classe utilitaire pour l'analyse de datasets Pandas.

    Encapsule les opérations communes d'analyse pour
    faciliter la réutilisation dans différents projets.
    """

    def __init__(self, df: pd.DataFrame, nom: str = "Dataset"):
        """
        Initialise l'analyseur avec un DataFrame.

        Args:
            df: Le DataFrame à analyser
            nom: Nom descriptif du dataset (pour les rapports)
        """
        self.df = df.copy()     # Copie défensive
        self.nom = nom
        self._rapport = []       # Attribut privé (convention _)
        print(f"Dataset '{nom}' chargé : {df.shape[0]} lignes × {df.shape[1]} colonnes")

    # ─── PROPRIÉTÉS ───────────────────────────────────────────
    @property
    def shape(self) -> tuple:
        """Dimensions du dataset."""
        return self.df.shape

    @property
    def colonnes_numeriques(self) -> List[str]:
        """Liste des colonnes numériques."""
        return self.df.select_dtypes(include=[np.number]).columns.tolist()

    @property
    def colonnes_texte(self) -> List[str]:
        """Liste des colonnes texte."""
        return self.df.select_dtypes(include=["object"]).columns.tolist()

    @property
    def taux_completude(self) -> pd.Series:
        """Pourcentage de valeurs non-nulles par colonne."""
        return (1 - self.df.isnull().mean()) * 100

    # ─── MÉTHODES D'ANALYSE ───────────────────────────────────
    def resume(self) -> None:
        """Affiche un résumé complet du dataset."""
        print(f"\n{'='*60}")
        print(f"RÉSUMÉ : {self.nom}")
        print(f"{'='*60}")
        print(f"Dimensions    : {self.df.shape[0]:,} lignes × {self.df.shape[1]} colonnes")
        print(f"Mémoire       : {self.df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
        print(f"\nTypes de données :")
        print(self.df.dtypes.value_counts())
        print(f"\nValeurs manquantes :")
        manquantes = self.df.isnull().sum()
        print(manquantes[manquantes > 0])

    def outliers_zscore(self, colonne: str, seuil: float = 3.0) -> pd.DataFrame:
        """
        Détecte les outliers avec la méthode Z-score.

        Un outlier est une valeur dont le Z-score > seuil
        (par défaut 3 écarts-types de la moyenne).

        Args:
            colonne: Nom de la colonne à analyser
            seuil: Z-score au-delà duquel une valeur est outlier

        Returns:
            DataFrame des lignes contenant des outliers
        """
        col_data = self.df[colonne].dropna()
        z_scores = np.abs((col_data - col_data.mean()) / col_data.std())
        masque_outliers = z_scores > seuil
        return self.df[self.df.index.isin(masque_outliers[masque_outliers].index)]

    def corrélations_avec(self, cible: str, top_n: int = 10) -> pd.Series:
        """
        Calcule les corrélations de toutes les colonnes avec une variable cible.

        Args:
            cible: Nom de la colonne cible
            top_n: Nombre de meilleures corrélations à retourner

        Returns:
            Series triée par corrélation absolue décroissante
        """
        corr_matrix = self.df[self.colonnes_numeriques].corr()
        corr_avec_cible = corr_matrix[cible].drop(cible)
        return corr_avec_cible.abs().sort_values(ascending=False).head(top_n)

    def __repr__(self) -> str:
        return f"DatasetAnalyzer('{self.nom}', shape={self.shape})"

    def __len__(self) -> int:
        return len(self.df)


# ─── UTILISATION ─────────────────────────────────────────────
# Génération données de test
np.random.seed(42)
df_test = pd.DataFrame({
    "prix":      np.random.lognormal(5, 1, 500),
    "surface":   np.random.normal(80, 20, 500),
    "chambres":  np.random.randint(1, 6, 500),
    "ville":     np.random.choice(["Paris", "Lyon", "Marseille"], 500),
    "annee":     np.random.randint(1970, 2024, 500)
})

# Ajouter des NaN artificiellement
df_test.loc[::20, "prix"] = np.nan  # Toutes les 20 lignes

# Utilisation de la classe
analyzeur = DatasetAnalyzer(df_test, "Immobilier")
print(analyzeur)              # DatasetAnalyzer('Immobilier', shape=(500, 5))
print(len(analyzeur))         # 500
analyzeur.resume()
print(analyzeur.taux_completude)
```

================================================================================
CHAPITRE 9 — MODULES ET PACKAGES ESSENTIELS
================================================================================

────────────────────────────────────────
9.1 OS ET PATHLIB : GESTION DES FICHIERS
────────────────────────────────────────

```python
import os
from pathlib import Path

# ─── PATHLIB (MODERNE, RECOMMANDÉ) ───────────────────────────
# Path fonctionne sur Windows, Mac, Linux automatiquement

# Chemins
p = Path("data") / "raw" / "ventes.csv"  # Opérateur / pour joindre !
print(p)            # data/raw/ventes.csv (ou data\raw\ventes.csv sur Windows)
print(p.exists())   # True si le fichier existe
print(p.is_file())  # True si c'est un fichier
print(p.is_dir())   # True si c'est un dossier
print(p.parent)     # data/raw
print(p.name)       # ventes.csv
print(p.stem)       # ventes
print(p.suffix)     # .csv

# Créer des dossiers
Path("data/raw").mkdir(parents=True, exist_ok=True)  # Crée data/ et data/raw/

# Lister les fichiers CSV dans un dossier
for fichier_csv in Path("data").rglob("*.csv"):  # Récursif
    print(fichier_csv)

# Lire un fichier texte
contenu = Path("config.json").read_text(encoding="utf-8")

# Écrire un fichier texte
Path("output.txt").write_text("Hello, Data !", encoding="utf-8")

# Chemin absolu
print(Path("data").resolve())  # /home/user/projet/data

# ─── OS : OPÉRATIONS SYSTÈME ─────────────────────────────────
# Lister les fichiers
fichiers = os.listdir("data")

# Variables d'environnement
home = os.environ.get("HOME", "/home/user")

# Obtenir l'extension
_, ext = os.path.splitext("fichier.csv")  # ext = ".csv"

# Taille d'un fichier
taille = os.path.getsize("data.csv")  # en octets
print(f"Taille : {taille / 1024 / 1024:.2f} MB")
```

────────────────────────────────────────
9.2 DATETIME : MANIPULATION DES DATES
────────────────────────────────────────

```python
from datetime import datetime, date, timedelta
import pandas as pd

# ─── DATES EN PYTHON PUR ─────────────────────────────────────
maintenant = datetime.now()
print(maintenant)     # 2024-01-15 10:30:45.123456

aujourd_hui = date.today()
print(aujourd_hui)    # 2024-01-15

# Créer une date précise
noel = datetime(2024, 12, 25, 0, 0, 0)

# Différence entre dates
diff = noel - maintenant
print(diff.days)      # Nombre de jours jusqu'à Noël

# Ajouter/soustraire du temps
demain = maintenant + timedelta(days=1)
hier   = maintenant - timedelta(days=1)
semaine_apres = maintenant + timedelta(weeks=1, hours=3)

# Formatter une date
print(maintenant.strftime("%d/%m/%Y"))          # "15/01/2024"
print(maintenant.strftime("%d %B %Y à %H:%M"))  # "15 January 2024 à 10:30"

# Parser une date (string -> datetime)
date_str = "15/01/2024"
date_obj = datetime.strptime(date_str, "%d/%m/%Y")

# ─── FORMATS DE DATE STRFTIME/STRPTIME ────────────────────────
# %Y -> 2024    (année 4 chiffres)
# %y -> 24      (année 2 chiffres)
# %m -> 01      (mois 01-12)
# %d -> 15      (jour 01-31)
# %H -> 10      (heure 00-23)
# %M -> 30      (minutes 00-59)
# %S -> 45      (secondes 00-59)
# %A -> Monday  (nom du jour en anglais)
# %B -> January (nom du mois en anglais)

# ─── DATES AVEC PANDAS ────────────────────────────────────────
dates = pd.Series(["2024-01-15", "2024-02-20", "2024-03-10"])
dates_parsees = pd.to_datetime(dates)

# Extraction de composantes
df_dates = pd.DataFrame({"date": pd.date_range("2024-01-01", periods=12, freq="ME")})
df_dates["annee"]        = df_dates["date"].dt.year
df_dates["mois"]         = df_dates["date"].dt.month
df_dates["nom_mois"]     = df_dates["date"].dt.month_name()
df_dates["trimestre"]    = df_dates["date"].dt.quarter
df_dates["jour_semaine"] = df_dates["date"].dt.day_name()
df_dates["est_weekend"]  = df_dates["date"].dt.dayofweek >= 5

print(df_dates.head())

# Intervalles de dates
debut = pd.Timestamp("2024-01-01")
fin   = pd.Timestamp("2024-12-31")
tous_jours = pd.date_range(debut, fin, freq="D")        # Chaque jour
tous_mois  = pd.date_range(debut, fin, freq="ME")       # Fin de chaque mois
tous_lundi = pd.date_range(debut, fin, freq="W-MON")    # Chaque lundi
```

────────────────────────────────────────
9.3 RE : EXPRESSIONS RÉGULIÈRES
────────────────────────────────────────

```python
import re

# ─── REGEX ESSENTIELS POUR LE NETTOYAGE DE DONNÉES ───────────

# Extraire des emails
texte = "Contact: alice@example.com ou bob.smith@corp.fr"
pattern_email = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
emails = re.findall(pattern_email, texte)
# ['alice@example.com', 'bob.smith@corp.fr']

# Extraire des numéros de téléphone
texte_tel = "Appelez le 06-12-34-56-78 ou le 0033 1 23 45 67 89"
pattern_tel = r'\b(?:\d{2}[-.\s]?){4}\d{2}\b'
telephones = re.findall(pattern_tel, texte_tel)

# Nettoyer du texte HTML
html = "<p>Bonjour <b>monde</b></p>"
propre = re.sub(r'<[^>]+>', '', html)  # Supprime toutes les balises HTML
# "Bonjour monde"

# Valider un code postal français
def est_code_postal_fr(code):
    return bool(re.match(r'^(?:0[1-9]|[1-8]\d|9[0-5])\d{3}$', str(code)))

print(est_code_postal_fr("75001"))  # True
print(est_code_postal_fr("00000"))  # False
print(est_code_postal_fr("99999"))  # False

# Extraire des nombres d'un texte
texte_mixte = "Le produit coûte 29,99 EUR et pèse 1.5 kg"
nombres = re.findall(r'\d+(?:[.,]\d+)?', texte_mixte)
# ['29,99', '1.5']

# Normaliser les espaces
texte_sale = "Nom  :  Alice    Dupont   "
texte_propre = re.sub(r'\s+', ' ', texte_sale).strip()
# "Nom : Alice Dupont"

# ─── APPLICATION PANDAS : NETTOYAGE DE COLONNES ───────────────
import pandas as pd

df = pd.DataFrame({
    "telephone": ["06-12-34-56-78", "0612345678", "06 12 34 56 78", None],
    "email":     ["ALICE@EXAMPLE.COM", "bob@corp.fr", "invalid_email", "charlie@test.com"]
})

# Normaliser les téléphones (supprimer tous les séparateurs)
df["tel_clean"] = df["telephone"].str.replace(r'[-.\s]', '', regex=True)
# ["0612345678", "0612345678", "0612345678", NaN]

# Normaliser les emails (minuscule)
df["email_clean"] = df["email"].str.lower()

# Valider les emails
df["email_valide"] = df["email"].str.match(r'^[^@]+@[^@]+\.[^@]+$')
```

================================================================================
CHAPITRE 10 — MANIPULATION DE FICHIERS
================================================================================

────────────────────────────────────────
10.1 FICHIERS CSV : LECTURE ET ÉCRITURE
────────────────────────────────────────

```python
import pandas as pd
import csv
from pathlib import Path

# ─── LECTURE CSV AVEC PANDAS ─────────────────────────────────
# Paramètres les plus utiles de read_csv

df = pd.read_csv(
    "ventes.csv",
    sep=",",                   # Séparateur (peut être ";" pour CSV français)
    encoding="utf-8",          # Encodage (utf-8, latin-1, cp1252...)
    header=0,                  # Ligne d'en-tête (0 = première ligne)
    index_col=None,            # Colonne à utiliser comme index (None = aucune)
    usecols=["id", "montant", "date"],  # Charger uniquement ces colonnes
    dtype={                    # Forcer les types à la lecture
        "id": int,
        "code_postal": str     # Garder en str pour préserver les "0" initiaux
    },
    parse_dates=["date"],      # Convertir automatiquement en datetime
    nrows=1000,                # Lire seulement les 1000 premières lignes
    skiprows=[1, 2],           # Ignorer les lignes 1 et 2
    na_values=["N/A", "NULL", "-", ""],  # Valeurs à considérer comme NaN
    low_memory=False,          # Évite l'avertissement sur types mixtes
    decimal=",",               # Pour CSV français (1.234,56 -> float)
    thousands=".",             # Séparateur des milliers
    comment="#",               # Ignorer les lignes commençant par #
)

# ─── CSV AVEC ENCODAGE DÉTECTION ─────────────────────────────
import chardet

def detect_encoding(filepath):
    """Détecte automatiquement l'encodage d'un fichier."""
    with open(filepath, "rb") as f:
        raw = f.read(100_000)  # Lire les 100ko
    return chardet.detect(raw)["encoding"]

# encoding = detect_encoding("fichier_inconnu.csv")
# df = pd.read_csv("fichier_inconnu.csv", encoding=encoding)

# ─── LECTURE DE GROS FICHIERS PAR CHUNKS ─────────────────────
def traiter_grand_csv(chemin, chunk_size=100_000):
    """
    Lit un grand CSV par morceaux pour économiser la RAM.
    Retourne les statistiques globales.
    """
    total_montant = 0
    total_lignes  = 0
    resultats = []

    for chunk in pd.read_csv(chemin, chunksize=chunk_size):
        # Traitement de chaque chunk
        chunk_propre = chunk.dropna(subset=["montant"])
        total_montant += chunk_propre["montant"].sum()
        total_lignes  += len(chunk_propre)
        resultats.append(chunk_propre.groupby("categorie")["montant"].sum())

        print(f"Traité : {total_lignes:,} lignes", end="\r")

    # Combiner tous les résultats
    df_final = pd.concat(resultats).groupby(level=0).sum()
    return df_final, total_montant, total_lignes

# ─── ÉCRITURE CSV ─────────────────────────────────────────────
df.to_csv(
    "sortie.csv",
    index=False,               # Ne pas sauvegarder l'index (recommandé)
    sep=",",                   # Séparateur
    encoding="utf-8-sig",      # utf-8-sig pour compatibilité Excel
    float_format="%.2f",       # Format des nombres décimaux
    date_format="%Y-%m-%d",    # Format des dates
)

# ─── CSV NATIF PYTHON (pour les cas simples) ──────────────────
# Lecture
with open("simple.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)  # Chaque ligne = dictionnaire
    for row in reader:
        print(row["nom"], row["age"])

# Écriture
colonnes = ["nom", "age", "ville"]
lignes = [
    {"nom": "Alice", "age": 25, "ville": "Paris"},
    {"nom": "Bob",   "age": 30, "ville": "Lyon"},
]

with open("sortie.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=colonnes)
    writer.writeheader()
    writer.writerows(lignes)
```

────────────────────────────────────────
10.2 FICHIERS JSON
────────────────────────────────────────

```python
import json
import pandas as pd
from pathlib import Path

# ─── LECTURE JSON ────────────────────────────────────────────
# JSON simple -> dictionnaire Python
with open("config.json", "r", encoding="utf-8") as f:
    config = json.load(f)

# String JSON -> dictionnaire
json_str = '{"nom": "Alice", "age": 25, "scores": [85, 92, 78]}'
data = json.loads(json_str)  # loads = "load string"
print(data["nom"])    # "Alice"
print(data["scores"]) # [85, 92, 78]

# ─── JSON IMBRIQUÉ -> PANDAS ──────────────────────────────────
json_complex = """[
    {"id": 1, "client": {"nom": "Alice", "ville": "Paris"},
     "commandes": [{"produit": "Laptop", "prix": 999}]},
    {"id": 2, "client": {"nom": "Bob",   "ville": "Lyon"},
     "commandes": [{"produit": "Phone", "prix": 599}, {"produit": "Case", "prix": 29}]}
]"""

data = json.loads(json_complex)

# json_normalize : aplatir le JSON imbriqué
df = pd.json_normalize(
    data,
    record_path="commandes",        # Expandre cette liste
    meta=["id", ["client", "nom"]], # Conserver ces champs
    meta_prefix="",
    sep="_"                         # Séparateur pour les champs imbriqués
)
print(df)
#     produit   prix   id  client_nom
# 0   Laptop   999.0    1      Alice
# 1   Phone    599.0    2      Bob
# 2   Case      29.0    2      Bob

# ─── ÉCRITURE JSON ────────────────────────────────────────────
data = {"nom": "Alice", "scores": [85, 92], "actif": True}

# Écriture dans fichier
with open("sortie.json", "w", encoding="utf-8") as f:
    json.dump(
        data, f,
        ensure_ascii=False,  # Préserver les caractères spéciaux (é, à, ü...)
        indent=4,            # Indentation pour lisibilité
        sort_keys=True       # Trier les clés alphabétiquement
    )

# Écriture dans string
json_str = json.dumps(data, ensure_ascii=False, indent=2)

# ─── PANDAS -> JSON ────────────────────────────────────────────
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})

# Différents formats JSON
df.to_json("sortie_records.json",   orient="records",  indent=2)
# [{"a":1,"b":3}, {"a":2,"b":4}]

df.to_json("sortie_columns.json",   orient="columns",  indent=2)
# {"a":{"0":1,"1":2},"b":{"0":3,"1":4}}

df.to_json("sortie_index.json",     orient="index",    indent=2)
# {"0":{"a":1,"b":3},"1":{"a":2,"b":4}}
```

────────────────────────────────────────
10.3 FICHIERS EXCEL
────────────────────────────────────────

```python
import pandas as pd
from openpyxl import load_workbook

# ─── LECTURE EXCEL ────────────────────────────────────────────
# pip install openpyxl xlrd

# Lire un fichier Excel
df = pd.read_excel(
    "rapport.xlsx",
    sheet_name="Ventes",     # Nom de l'onglet (ou 0 pour le premier)
    header=0,
    usecols="A:E",           # Colonnes A à E
    skiprows=2,              # Ignorer les 2 premières lignes
    nrows=1000               # Lire seulement 1000 lignes
)

# Lire tous les onglets
tous_onglets = pd.read_excel("rapport.xlsx", sheet_name=None)
# Retourne un dictionnaire : {"Onglet1": df1, "Onglet2": df2, ...}

for nom_onglet, df_onglet in tous_onglets.items():
    print(f"Onglet '{nom_onglet}' : {df_onglet.shape}")

# ─── ÉCRITURE EXCEL ──────────────────────────────────────────
# Écriture simple
df.to_excel("sortie.xlsx", sheet_name="Analyse", index=False)

# Écriture multi-onglets
with pd.ExcelWriter("rapport_complet.xlsx", engine="openpyxl") as writer:
    df_ventes.to_excel(writer, sheet_name="Ventes",    index=False)
    df_clients.to_excel(writer, sheet_name="Clients",  index=False)
    df_produits.to_excel(writer, sheet_name="Produits", index=False)

# ─── EXCEL AVEC MISE EN FORME ────────────────────────────────
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter

with pd.ExcelWriter("rapport_formate.xlsx", engine="openpyxl") as writer:
    df.to_excel(writer, sheet_name="Données", index=False)

    # Accéder à la feuille
    ws = writer.sheets["Données"]

    # Mettre en forme l'en-tête
    for col_num, colonne in enumerate(df.columns, 1):
        cell = ws.cell(row=1, column=col_num)
        cell.font = Font(bold=True, color="FFFFFF")
        cell.fill = PatternFill("solid", fgColor="2E75B6")  # Bleu
        cell.alignment = Alignment(horizontal="center")

    # Ajuster la largeur des colonnes automatiquement
    for col_num, colonne in enumerate(df.columns, 1):
        lettre = get_column_letter(col_num)
        max_width = max(
            len(str(colonne)),
            df[colonne].astype(str).str.len().max()
        )
        ws.column_dimensions[lettre].width = min(max_width + 2, 50)
```

────────────────────────────────────────
10.4 FORMAT PARQUET : LE FUTUR DES DONNÉES
────────────────────────────────────────

```python
import pandas as pd

# ─── POURQUOI PARQUET ? ───────────────────────────────────────
# CSV : texte brut, universel, mais lent et volumineux
# Parquet : binaire, colonnaire, TRÈS rapide, compressé

# Avantages Parquet vs CSV :
#   - 5-10x plus petit que CSV
#   - 10-100x plus rapide pour lire de grandes tables
#   - Préserve les types (pas de re-conversion)
#   - Supporte la lecture de colonnes sélectives

# pip install pyarrow  (ou fastparquet)

# Écriture Parquet
df.to_parquet(
    "donnees.parquet",
    engine="pyarrow",         # ou "fastparquet"
    compression="snappy",      # Compression rapide (ou "gzip", "brotli")
    index=False
)

# Lecture Parquet (lire seulement certaines colonnes)
df_partiel = pd.read_parquet(
    "donnees.parquet",
    columns=["id", "montant", "date"],  # Seulement ces colonnes !
    engine="pyarrow"
)

# ─── COMPARAISON DES PERFORMANCES ─────────────────────────────
import time
import numpy as np

# Générer un grand dataset de test
n = 1_000_000
df_perf = pd.DataFrame({
    "id":       range(n),
    "valeur":   np.random.randn(n),
    "categorie": np.random.choice(["A", "B", "C"], n),
    "date":     pd.date_range("2020-01-01", periods=n, freq="s")
})

# Mesurer les temps
t0 = time.time()
df_perf.to_csv("test.csv", index=False)
print(f"Écriture CSV   : {time.time()-t0:.2f}s")

t0 = time.time()
df_perf.to_parquet("test.parquet", index=False)
print(f"Écriture Parquet: {time.time()-t0:.2f}s")

t0 = time.time()
pd.read_csv("test.csv")
print(f"Lecture CSV    : {time.time()-t0:.2f}s")

t0 = time.time()
pd.read_parquet("test.parquet")
print(f"Lecture Parquet: {time.time()-t0:.2f}s")

# Exemple de sortie typique :
# Écriture CSV    : 12.45s  -> 156 MB
# Écriture Parquet:  1.23s  ->  18 MB (9x plus petit !)
# Lecture CSV     :  8.32s
# Lecture Parquet :  0.89s  (10x plus rapide !)
```

────────────────────────────────────────
10.5 BONNES PRATIQUES : GESTION DES FICHIERS
────────────────────────────────────────

```python
# ─── RÈGLES D'OR ─────────────────────────────────────────────

# RÈGLE 1 : Ne jamais modifier les données brutes originales
# Les données raw sont sacrées — toujours travailler sur une copie
RAW_DIR   = Path("data/raw")
PROC_DIR  = Path("data/processed")
raw_path  = RAW_DIR / "ventes_original.csv"
proc_path = PROC_DIR / "ventes_clean.csv"

df = pd.read_csv(raw_path)  # Lecture depuis raw
df_propre = nettoyer(df)    # Traitement
df_propre.to_csv(proc_path) # Sauvegarde dans processed

# RÈGLE 2 : Vérifier que le fichier existe avant de le lire
def charger_securise(chemin: str) -> pd.DataFrame:
    p = Path(chemin)
    if not p.exists():
        raise FileNotFoundError(f"Fichier introuvable : {chemin}")
    if p.suffix not in [".csv", ".parquet", ".xlsx", ".json"]:
        raise ValueError(f"Format non supporté : {p.suffix}")

    loaders = {
        ".csv":     lambda: pd.read_csv(p),
        ".parquet": lambda: pd.read_parquet(p),
        ".xlsx":    lambda: pd.read_excel(p),
        ".json":    lambda: pd.read_json(p),
    }
    return loaders[p.suffix]()

# RÈGLE 3 : Toujours spécifier l'encodage
# Évite les erreurs UnicodeDecodeError surprises
df = pd.read_csv("fichier.csv", encoding="utf-8")  # Toujours !

# RÈGLE 4 : Utiliser with pour les fichiers
# Garantit la fermeture même en cas d'erreur
with open("fichier.txt", "r") as f:
    contenu = f.read()
# Le fichier est automatiquement fermé ici

# RÈGLE 5 : Versionner les fichiers de données
from datetime import datetime

def sauvegarder_avec_version(df, nom_base):
    """Sauvegarde un DataFrame avec timestamp pour versionnage."""
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    chemin = Path(f"data/processed/{nom_base}_{timestamp}.parquet")
    df.to_parquet(chemin)
    print(f"Sauvegardé : {chemin}")
    return chemin
```

────────────────────────────────────────
10.6 EXERCICES PRATIQUES — CHAPITRES 6-10
────────────────────────────────────────

EXERCICES FACILES :
  1. Créez un dictionnaire de fréquence de mots avec Counter
     pour le texte : "the quick brown fox jumps over the lazy dog"

  2. Chargez un fichier CSV, sélectionnez 3 colonnes spécifiques,
     et sauvegardez en Parquet

  3. Parsez la liste de strings de dates suivante en datetime :
     ["15/01/2024", "20-02-2024", "2024.03.10"]

EXERCICES INTERMÉDIAIRES :
  4. Créez un générateur qui lit un CSV par chunks de 5000 lignes
     et calcule la somme totale d'une colonne numérique

  5. Utilisez regex pour extraire tous les prix (format: X.XX EUR)
     d'un texte et retournez leur somme

  6. Créez une classe "DataLoader" qui supporte les formats
     CSV, JSON, Excel et Parquet avec une interface unifiée

EXERCICES AVANCÉS :
  7. Implémentez un pipeline de chargement de données qui :
     - détecte automatiquement l'encodage
     - infère les types de données
     - gère les erreurs et log les problèmes
     - sauvegarde en Parquet

  8. Créez un système de cache de fichiers qui évite de recharger
     un fichier s'il n'a pas été modifié depuis la dernière lecture

  9. Écrivez un script qui fusionne 50 fichiers CSV mensuels
     en un seul DataFrame annuel, en gérant les colonnes manquantes

────────────────────────────────────────
10.7 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────

CORRIGÉ EXERCICE 6 (Classe DataLoader) :
```python
import pandas as pd
from pathlib import Path
from typing import Optional, List

class DataLoader:
    """
    Chargeur universel de données supportant CSV, JSON, Excel, Parquet.
    """
    FORMATS_SUPPORTES = {
        ".csv":     "read_csv",
        ".json":    "read_json",
        ".xlsx":    "read_excel",
        ".xls":     "read_excel",
        ".parquet": "read_parquet",
    }

    def charger(
        self,
        chemin: str,
        colonnes: Optional[List[str]] = None,
        **kwargs
    ) -> pd.DataFrame:
        """
        Charge un fichier de données quel que soit son format.

        Args:
            chemin: Chemin du fichier
            colonnes: Liste des colonnes à charger (None = toutes)
            **kwargs: Arguments supplémentaires passés à pandas

        Returns:
            DataFrame chargé

        Raises:
            FileNotFoundError: Si le fichier n'existe pas
            ValueError: Si le format n'est pas supporté
        """
        p = Path(chemin)

        if not p.exists():
            raise FileNotFoundError(f"Fichier introuvable : {chemin}")

        ext = p.suffix.lower()
        if ext not in self.FORMATS_SUPPORTES:
            raise ValueError(
                f"Format '{ext}' non supporté. "
                f"Formats acceptés : {list(self.FORMATS_SUPPORTES.keys())}"
            )

        # Sélectionner la bonne fonction pandas
        methode_nom = self.FORMATS_SUPPORTES[ext]
        methode = getattr(pd, methode_nom)

        # Paramètre "columns" vs "usecols" selon le format
        if colonnes:
            if ext == ".parquet":
                kwargs["columns"] = colonnes
            elif ext in [".csv", ".xlsx"]:
                kwargs["usecols"] = colonnes

        df = methode(chemin, **kwargs)
        print(f"Chargé : {chemin} ({df.shape[0]:,} lignes × {df.shape[1]} colonnes)")
        return df

    def sauvegarder(self, df: pd.DataFrame, chemin: str, **kwargs) -> None:
        """Sauvegarde un DataFrame dans le format détecté par l'extension."""
        p = Path(chemin)
        p.parent.mkdir(parents=True, exist_ok=True)

        ext = p.suffix.lower()
        sauvegardeurs = {
            ".csv":     lambda: df.to_csv(chemin, index=False, **kwargs),
            ".parquet": lambda: df.to_parquet(chemin, index=False, **kwargs),
            ".json":    lambda: df.to_json(chemin, orient="records", indent=2),
            ".xlsx":    lambda: df.to_excel(chemin, index=False, **kwargs),
        }

        if ext not in sauvegardeurs:
            raise ValueError(f"Format '{ext}' non supporté pour l'écriture")

        sauvegardeurs[ext]()
        print(f"Sauvegardé : {chemin}")


# Test
loader = DataLoader()
# df = loader.charger("data.csv", colonnes=["nom", "age"])
# loader.sauvegarder(df, "output.parquet")
```

================================================================================
RÉSUMÉ DE LA PARTIE 2
================================================================================

Dans cette partie, nous avons couvert :

  [OK] Mutabilité/immuabilité et ses pièges avec Pandas
  [OK] Type hints pour un code professionnel et documenté
  [OK] Structures avancées : defaultdict, Counter, sets, générateurs
  [OK] List/dict comprehensions avancées
  [OK] Fonctions d'ordre supérieur : map, filter, reduce
  [OK] Décorateurs : timeit, validation, cache LRU
  [OK] Classes Python orientées data science
  [OK] Modules essentiels : os, pathlib, datetime, re
  [OK] Manipulation complète CSV, JSON, Excel, Parquet
  [OK] Bonnes pratiques de gestion de fichiers

PROCHAINE ÉTAPE :
  Partie 3 — NumPy : le moteur de calcul numérique

================================================================================
FIN DE LA PARTIE 2
================================================================================

================================================================================
GUIDE COMPLET D'ANALYSE DE DONNÉES AVEC PYTHON
PARTIE 3 — NUMPY : LE MOTEUR DE CALCUL NUMÉRIQUE
================================================================================
Chapitres : 11-14 | Introduction, Tableaux, Opérations vectorisées, Broadcasting
================================================================================

TABLE DES MATIÈRES — PARTIE 3
──────────────────────────────
Chapitre 11 : Introduction à NumPy
Chapitre 12 : Tableaux (ndarrays)
Chapitre 13 : Opérations vectorisées
Chapitre 14 : Broadcasting

================================================================================
CHAPITRE 11 — INTRODUCTION À NUMPY
================================================================================

────────────────────────────────────────
11.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────

Qu'est-ce que NumPy ?

NumPy (Numerical Python) est LA bibliothèque fondamentale pour le calcul
numérique en Python. Pratiquement toutes les bibliothèques data science
(Pandas, Scikit-learn, TensorFlow) sont construites sur NumPy.

Pourquoi NumPy existe-t-il ?

Python est lent pour les calculs numériques massifs :
  - Une liste Python stocke des pointeurs vers des objets Python
  - Chaque opération implique du type-checking, de la gestion mémoire...
  - Résultat : boucles Python sur des millions de valeurs = TRÈS lent

NumPy résout ce problème :
  - Tableaux contigus en mémoire (comme C/C++)
  - Opérations implémentées en C compilé (ultra-rapides)
  - Vectorisation : appliquer une opération sur tout le tableau sans boucle
  - Broadcasting : opérations entre tableaux de formes différentes

Performance comparée :
  Additionner 1 million de nombres :
    Python pure (liste + boucle) : ~500ms
    NumPy                        : ~1ms   (500x plus rapide !)

────────────────────────────────────────
11.2 COMPARAISON PYTHON LISTE VS NUMPY ARRAY
────────────────────────────────────────

```python
import numpy as np
import time

# ─── DÉMONSTRATION DE LA DIFFÉRENCE DE PERFORMANCE ───────────
n = 10_000_000  # 10 millions d'éléments

# Avec des listes Python
liste = list(range(n))
debut = time.time()
resultat_liste = [x * 2 for x in liste]
print(f"Liste Python    : {time.time()-debut:.3f}s")  # ~1.5s

# Avec NumPy
array = np.arange(n)
debut = time.time()
resultat_numpy = array * 2
print(f"NumPy array     : {time.time()-debut:.3f}s")  # ~0.02s <- 75x plus rapide !

# ─── DIFFÉRENCES FONDAMENTALES ────────────────────────────────
# 1. TYPE UNIFORME : NumPy array = un seul type (plus efficace)
arr_int   = np.array([1, 2, 3])          # dtype: int64
arr_float = np.array([1.0, 2.5, 3.7])   # dtype: float64
arr_bool  = np.array([True, False, True]) # dtype: bool

liste_mixte = [1, "deux", 3.0, True]     # Liste Python = types mixtes OK
arr_mixte   = np.array([1, "deux", 3.0]) # NumPy -> tous convertis en string !
print(arr_mixte.dtype)  # <U21 (Unicode string)

# 2. MÉMOIRE CONTIGUË
print(arr_int.itemsize)   # 8 octets par entier (int64)
print(arr_int.nbytes)     # 24 octets total (3 × 8)

# Comparaison mémoire
import sys
liste_1000 = list(range(1000))
array_1000 = np.arange(1000, dtype=np.int64)

print(f"Mémoire liste  : {sys.getsizeof(liste_1000):,} octets")  # ~8856 octets
print(f"Mémoire array  : {array_1000.nbytes:,} octets")           # 8000 octets
# NumPy est ~10% plus compact pour les entiers, mais beaucoup plus
# compact pour les float et les grands tableaux

# 3. OPÉRATIONS ÉLÉMENT PAR ÉLÉMENT (sans boucle)
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + b)    # [11, 22, 33, 44, 55]  — Pas de boucle !
print(a * b)    # [10, 40, 90, 160, 250]
print(a ** 2)   # [1, 4, 9, 16, 25]
print(a > 3)    # [False, False, False, True, True]
```

================================================================================
CHAPITRE 12 — TABLEAUX NUMPY (NDARRAYS)
================================================================================

────────────────────────────────────────
12.1 CRÉATION DE TABLEAUX
────────────────────────────────────────

```python
import numpy as np

# ─── CRÉATION DEPUIS DES DONNÉES EXISTANTES ───────────────────
# 1D array (vecteur)
v1 = np.array([1, 2, 3, 4, 5])
print(v1.shape)   # (5,)    <- tuple avec 1 dimension
print(v1.ndim)    # 1

# 2D array (matrice)
m2 = np.array([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
])
print(m2.shape)   # (3, 3)  <- 3 lignes, 3 colonnes
print(m2.ndim)    # 2

# 3D array (tenseur — utilisé en Deep Learning)
t3 = np.array([
    [[1, 2], [3, 4]],
    [[5, 6], [7, 8]]
])
print(t3.shape)   # (2, 2, 2)  <- 2 "couches", 2 lignes, 2 colonnes
print(t3.ndim)    # 3

# ─── CRÉATION DE TABLEAUX SPÉCIAUX ───────────────────────────
# Tableaux de zéros
zeros = np.zeros((3, 4))          # 3×4 de zeros float64
zeros_int = np.zeros((3, 4), dtype=int)  # 3×4 de zeros int

# Tableaux de uns
ones = np.ones((2, 3, 4))         # 3D : 2×3×4 de 1.0

# Tableau identité (matrice diagonale)
I = np.eye(4)                     # Matrice identité 4×4

# Tableau de valeur constante
cinqs = np.full((3, 3), 5)        # 3×3 rempli de 5

# Tableau non initialisé (valeurs aléatoires de la RAM — DANGER)
vide = np.empty((2, 3))           # Valeurs quelconques !

# ─── SÉQUENCES NUMÉRIQUES ─────────────────────────────────────
# arange : équivalent de range() pour NumPy
a1 = np.arange(10)           # [0, 1, 2, ..., 9]
a2 = np.arange(0, 10, 2)     # [0, 2, 4, 6, 8]  (step=2)
a3 = np.arange(1.0, 2.0, 0.1)  # [1.0, 1.1, ..., 1.9]

# linspace : N points équidistants entre start et stop
l1 = np.linspace(0, 1, 11)    # 11 points de 0.0 à 1.0
# [0.0, 0.1, 0.2, ..., 1.0]

l2 = np.linspace(0, 2*np.pi, 100)  # 100 points pour une courbe sin

# logspace : N points en échelle logarithmique
log = np.logspace(0, 3, 4)    # [10^0, 10^1, 10^2, 10^3] = [1, 10, 100, 1000]

# ─── TABLEAUX ALÉATOIRES ──────────────────────────────────────
np.random.seed(42)  # TOUJOURS fixer le seed pour la reproductibilité !

# Distribution uniforme [0, 1[
r1 = np.random.rand(3, 4)      # 3×4 nombres entre 0 et 1

# Distribution normale (Gaussienne) : μ=0, σ=1
r2 = np.random.randn(1000)     # 1000 nombres ~ N(0,1)

# Distribution normale personnalisée : μ=100, σ=15 (QI)
qi = np.random.normal(loc=100, scale=15, size=10000)

# Distribution uniforme d'entiers
des = np.random.randint(1, 7, size=(1000, 6))  # 1000 lancers de 6 dés

# Choisir parmi des valeurs
couleurs = np.random.choice(["Rouge", "Bleu", "Vert"], size=50)
couleurs_ponderes = np.random.choice(
    ["Pile", "Face"],
    size=1000,
    p=[0.6, 0.4]  # Pièce truquée
)

# Mélanger un tableau
arr = np.arange(10)
np.random.shuffle(arr)      # Modifie en place
arr2 = np.random.permutation(arr)  # Retourne une copie mélangée
```

────────────────────────────────────────
12.2 ATTRIBUTS ET PROPRIÉTÉS DES ARRAYS
────────────────────────────────────────

```python
import numpy as np

arr = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)

print(arr.shape)     # (2, 3)    <- (lignes, colonnes)
print(arr.ndim)      # 2         <- nombre de dimensions
print(arr.size)      # 6         <- nombre total d'éléments
print(arr.dtype)     # float32   <- type des données
print(arr.itemsize)  # 4         <- octets par élément (float32 = 4 octets)
print(arr.nbytes)    # 24        <- octets total (6 × 4)

# ─── TYPES NUMPY (DTYPES) ─────────────────────────────────────
# Type        | Taille  | Description           | Plage
# ──────────────────────────────────────────────────────────────
# bool        | 1 bit   | Booléen               | True/False
# int8        | 1 octet | Entier court          | -128 à 127
# int16       | 2 octet | Entier                | -32768 à 32767
# int32       | 4 octet | Entier standard       | ~±2 milliards
# int64       | 8 octet | Grand entier          | ~±9×10^18
# uint8       | 1 octet | Entier non-signé      | 0 à 255 (images !)
# float16     | 2 octet | Demi-précision        | Moins précis, moins de RAM
# float32     | 4 octet | Simple précision      | Bon compromis (ML)
# float64     | 8 octet | Double précision      | Défaut Python
# complex128  | 16 octet| Nombre complexe       | a + bj

# Choisir le bon type selon le besoin :
ages    = np.array([25, 30, 45], dtype=np.int8)    # 0-127 -> int8 suffit
pixels  = np.array([255, 128, 0], dtype=np.uint8)  # Images : 0-255
poids   = np.array([70.5, 82.1], dtype=np.float32) # ML : float32 économise RAM
revenus = np.array([50000, 75000], dtype=np.int32) # Euros

# Conversion de type (casting)
arr_float = np.array([1.7, 2.9, 3.1])
arr_int   = arr_float.astype(np.int64)  # [1, 2, 3] <- TRONCATURE (pas arrondi)
arr_str   = arr_float.astype(str)       # ['1.7', '2.9', '3.1']
```

────────────────────────────────────────
12.3 INDEXAGE ET SLICING
────────────────────────────────────────

```python
import numpy as np

# ─── INDEXAGE 1D ────────────────────────────────────────────
arr = np.array([10, 20, 30, 40, 50, 60, 70, 80])

print(arr[0])      # 10   (premier)
print(arr[-1])     # 80   (dernier)
print(arr[2])      # 30   (index 2)

# Slicing [début:fin:pas]
print(arr[2:5])    # [30, 40, 50]  (index 2, 3, 4)
print(arr[:3])     # [10, 20, 30]  (du début à 2)
print(arr[5:])     # [60, 70, 80]  (de 5 à la fin)
print(arr[::2])    # [10, 30, 50, 70]  (tous les 2)
print(arr[::-1])   # [80, 70, 60, 50, 40, 30, 20, 10]  (inversé)

# ─── INDEXAGE 2D ─────────────────────────────────────────────
M = np.array([
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
])
# Shape : (3, 4) = 3 lignes, 4 colonnes

print(M[0, 0])     # 1    (ligne 0, colonne 0)
print(M[1, 2])     # 7    (ligne 1, colonne 2)
print(M[-1, -1])   # 12   (dernière ligne, dernière colonne)

# Sélectionner une ligne entière
print(M[0, :])     # [1, 2, 3, 4]    (ligne 0)
print(M[1])        # [5, 6, 7, 8]    (ligne 1, syntaxe simplifiée)

# Sélectionner une colonne entière
print(M[:, 0])     # [1, 5, 9]   (colonne 0)
print(M[:, 2])     # [3, 7, 11]  (colonne 2)

# Sous-matrice (slicing 2D)
print(M[0:2, 1:3])
# [[2, 3],
#  [6, 7]]

# ─── ATTENTION : LES SLICES SONT DES VUES, PAS DES COPIES ────
arr_original = np.array([1, 2, 3, 4, 5])
vue = arr_original[1:4]   # VUE (pas une copie !)
vue[0] = 99               # Modifie AUSSI arr_original !
print(arr_original)       # [ 1, 99,  3,  4,  5]  <- MODIFIÉ !

# Pour une vraie copie :
copie = arr_original[1:4].copy()
copie[0] = 0              # N'affecte PAS arr_original

# ─── INDEXAGE FANTAISIE (FANCY INDEXING) ─────────────────────
# Sélectionner des indices spécifiques (non-contigus)
arr = np.array([10, 20, 30, 40, 50, 60])
indices = [0, 2, 5]
print(arr[indices])    # [10, 30, 60]  <- copie, pas une vue !

# Sur une matrice 2D
M = np.array([[1, 2], [3, 4], [5, 6], [7, 8]])
lignes_select = M[[0, 2, 3], :]   # Lignes 0, 2 et 3
print(lignes_select)
# [[1, 2],
#  [5, 6],
#  [7, 8]]

# ─── INDEXAGE BOOLÉEN (MASQUES) ──────────────────────────────
# C'est LA technique la plus puissante en NumPy/Pandas !
arr = np.array([15, 8, 22, 3, 17, 11, 25, 6])

# Créer un masque
masque_grands = arr > 10
print(masque_grands)  # [True, False, True, False, True, True, True, False]

# Appliquer le masque (filtre)
grands = arr[masque_grands]
print(grands)  # [15, 22, 17, 11, 25]

# Syntaxe directe (sans variable intermédiaire)
print(arr[arr > 10])        # [15, 22, 17, 11, 25]
print(arr[(arr > 10) & (arr < 20)])  # [15, 17, 11]  <- ET logique
print(arr[(arr < 5) | (arr > 20)])   # [3, 22, 25]   <- OU logique

# Modification via masque
arr_copie = arr.copy()
arr_copie[arr_copie < 10] = 0   # Mettre à 0 les valeurs < 10
print(arr_copie)  # [15, 0, 22, 0, 17, 11, 25, 0]
```

────────────────────────────────────────
12.4 RESHAPE ET MANIPULATION DE FORME
────────────────────────────────────────

```python
import numpy as np

# ─── RESHAPE : CHANGER LA FORME ──────────────────────────────
arr = np.arange(12)   # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]

# Transformer en matrice 3×4
M = arr.reshape(3, 4)
# [[ 0,  1,  2,  3],
#  [ 4,  5,  6,  7],
#  [ 8,  9, 10, 11]]

# -1 : NumPy calcule automatiquement la dimension manquante
M2 = arr.reshape(4, -1)  # 4 lignes, NumPy calcule 3 colonnes
M3 = arr.reshape(-1, 6)  # NumPy calcule 2 lignes, 6 colonnes

# ─── APLATISSEMENT ───────────────────────────────────────────
M = np.array([[1, 2, 3], [4, 5, 6]])

flat1 = M.flatten()      # Retourne une COPIE 1D
flat2 = M.ravel()        # Retourne une VUE 1D (plus efficace)

print(flat1)  # [1, 2, 3, 4, 5, 6]

# ─── TRANSPOSITION ───────────────────────────────────────────
M = np.array([[1, 2, 3], [4, 5, 6]])  # Shape: (2, 3)
MT = M.T                               # Shape: (3, 2)
# [[1, 4],
#  [2, 5],
#  [3, 6]]

# ─── AJOUT DE DIMENSIONS ────────────────────────────────────
arr_1d = np.array([1, 2, 3])     # Shape: (3,)

# Ajouter une dimension pour créer un vecteur colonne
col = arr_1d[:, np.newaxis]       # Shape: (3, 1)
# [[1],
#  [2],
#  [3]]

# Ajouter une dimension pour créer un vecteur ligne
ligne = arr_1d[np.newaxis, :]     # Shape: (1, 3)
# [[1, 2, 3]]

# Même chose avec expand_dims
col2 = np.expand_dims(arr_1d, axis=1)   # Shape: (3, 1)
ligne2 = np.expand_dims(arr_1d, axis=0)  # Shape: (1, 3)

# ─── CONCATENATION ───────────────────────────────────────────
a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])

# Verticalement (empiler les lignes)
h = np.vstack([a, b])    # Shape: (4, 2)
# [[1, 2],
#  [3, 4],
#  [5, 6],
#  [7, 8]]

# Horizontalement (empiler les colonnes)
v = np.hstack([a, b])    # Shape: (2, 4)
# [[1, 2, 5, 6],
#  [3, 4, 7, 8]]

# np.concatenate : général
h2 = np.concatenate([a, b], axis=0)  # Comme vstack
v2 = np.concatenate([a, b], axis=1)  # Comme hstack

# ─── DIVISION (SPLIT) ────────────────────────────────────────
arr = np.arange(12)
p1, p2, p3 = np.split(arr, 3)     # Divise en 3 parties égales
# [0,1,2,3], [4,5,6,7], [8,9,10,11]

matrices = np.vsplit(M, 2)         # Divise verticalement
```

================================================================================
CHAPITRE 13 — OPÉRATIONS VECTORISÉES
================================================================================

────────────────────────────────────────
13.1 OPÉRATIONS ARITHMÉTIQUES
────────────────────────────────────────

```python
import numpy as np

# ─── OPÉRATIONS ÉLÉMENT PAR ÉLÉMENT ─────────────────────────
a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + b)   # [11, 22, 33, 44, 55]
print(a - b)   # [-9, -18, -27, -36, -45]
print(a * b)   # [10, 40, 90, 160, 250]
print(a / b)   # [0.1, 0.1, 0.1, 0.1, 0.1]
print(a ** 2)  # [1, 4, 9, 16, 25]

# Opération scalaire (broadcasting simple)
print(a + 100)    # [101, 102, 103, 104, 105]
print(a * 2)      # [2, 4, 6, 8, 10]
print(a / 5)      # [0.2, 0.4, 0.6, 0.8, 1.0]

# ─── FONCTIONS UNIVERSELLES (UFUNCS) ─────────────────────────
# Appliquées sur chaque élément, très rapides (implémentées en C)

arr = np.array([1, 4, 9, 16, 25])

# Mathématiques
print(np.sqrt(arr))        # [1., 2., 3., 4., 5.]
print(np.abs([-1, -2, 3])) # [1, 2, 3]
print(np.log(arr))         # log naturel
print(np.log2(arr))        # log base 2
print(np.log10(arr))       # log base 10
print(np.exp([0, 1, 2]))   # [1., 2.718..., 7.389...]

# Trigonométrie
angles = np.linspace(0, 2*np.pi, 7)
print(np.sin(angles))      # [0, 0.87, 0.87, 0, -0.87, -0.87, 0]
print(np.cos(angles))
print(np.tan(angles))

# Arrondi
arr = np.array([1.4, 1.5, 1.6, 2.49, 2.51])
print(np.round(arr))       # [1., 2., 2., 2., 3.]   (arrondi classique)
print(np.floor(arr))       # [1., 1., 1., 2., 2.]   (arrondi vers le bas)
print(np.ceil(arr))        # [2., 2., 2., 3., 3.]   (arrondi vers le haut)
print(np.trunc(arr))       # [1., 1., 1., 2., 2.]   (troncature)

# Minimum/Maximum entre deux arrays
a = np.array([1, 5, 3, 7])
b = np.array([4, 2, 6, 2])
print(np.maximum(a, b))    # [4, 5, 6, 7]  (élément par élément)
print(np.minimum(a, b))    # [1, 2, 3, 2]

# Clipping (limiter entre min et max)
arr = np.array([-5, -2, 0, 3, 8, 15])
print(np.clip(arr, 0, 10)) # [0, 0, 0, 3, 8, 10]
```

────────────────────────────────────────
13.2 STATISTIQUES DESCRIPTIVES
────────────────────────────────────────

```python
import numpy as np

# ─── STATISTIQUES SUR UN ARRAY ────────────────────────────────
np.random.seed(42)
notes = np.random.normal(12, 3, 1000)  # 1000 notes, moy=12, std=3
notes = np.clip(notes, 0, 20)          # Limiter à [0, 20]

# Statistiques globales
print(f"Moyenne    : {np.mean(notes):.2f}")
print(f"Médiane    : {np.median(notes):.2f}")
print(f"Écart-type : {np.std(notes):.2f}")
print(f"Variance   : {np.var(notes):.2f}")
print(f"Min        : {np.min(notes):.2f}")
print(f"Max        : {np.max(notes):.2f}")
print(f"Total      : {np.sum(notes):.2f}")

# Percentiles et quantiles
p25 = np.percentile(notes, 25)    # 1er quartile
p50 = np.percentile(notes, 50)    # Médiane
p75 = np.percentile(notes, 75)    # 3ème quartile
iqr  = p75 - p25                  # Intervalle interquartile
print(f"Q1={p25:.2f}, Q2={p50:.2f}, Q3={p75:.2f}, IQR={iqr:.2f}")

# Percentiles multiples d'un coup
percentiles = np.percentile(notes, [0, 10, 25, 50, 75, 90, 100])
print(percentiles)

# ─── STATISTIQUES PAR AXE (2D) ───────────────────────────────
M = np.array([
    [85, 90, 78, 92],   # Étudiant 1 : 4 matières
    [70, 65, 80, 75],   # Étudiant 2
    [95, 88, 92, 91],   # Étudiant 3
    [60, 72, 68, 65],   # Étudiant 4
])
# Shape : (4, 4) — 4 étudiants, 4 matières

# axis=0 -> opération sur les LIGNES (résultat par colonne)
# = moyenne par matière
moy_par_matiere = np.mean(M, axis=0)
print(f"Moy par matière  : {moy_par_matiere}")
# [77.5, 78.75, 79.5, 80.75]

# axis=1 -> opération sur les COLONNES (résultat par ligne)
# = moyenne par étudiant
moy_par_etudiant = np.mean(M, axis=1)
print(f"Moy par étudiant : {moy_par_etudiant}")
# [86.25, 72.5, 91.5, 66.25]

# Pas d'axis -> statistique globale
print(f"Moy globale      : {np.mean(M):.2f}")

# ─── AGRÉGATIONS AVANCÉES ────────────────────────────────────
notes_2d = np.array([[15, 8, 17], [12, 14, 11], [9, 16, 13]])

# cumsum : somme cumulée
print(np.cumsum(notes_2d.flatten()))  # [15, 23, 40, 52, 66, 77, ...]

# diff : différences consécutives (pour les séries temporelles)
prix = np.array([100, 105, 103, 110, 108, 115])
variations = np.diff(prix)  # [5, -2, 7, -2, 7]

# gradient : dérivée numérique
print(np.gradient(prix))  # Approximation de la dérivée

# argmin, argmax : INDEX du min/max
arr = np.array([30, 10, 50, 20, 40])
print(np.argmin(arr))  # 1  (index 1 -> valeur 10)
print(np.argmax(arr))  # 2  (index 2 -> valeur 50)

# Pour 2D
M = np.random.randint(0, 100, (4, 5))
print(np.argmin(M, axis=0))  # Index du minimum de chaque colonne
print(np.argmax(M, axis=1))  # Index du maximum de chaque ligne
```

────────────────────────────────────────
13.3 ALGÈBRE LINÉAIRE AVEC NUMPY
────────────────────────────────────────

```python
import numpy as np

# ─── MULTIPLICATION DE MATRICES ──────────────────────────────
A = np.array([[1, 2], [3, 4]])   # Matrice 2×2
B = np.array([[5, 6], [7, 8]])   # Matrice 2×2

# Produit élément par élément (PAS la multiplication matricielle !)
print(A * B)
# [[ 5, 12],
#  [21, 32]]

# Produit matriciel (dot product)
print(A @ B)       # Python 3.5+ — opérateur @
print(np.dot(A, B))  # Équivalent
# [[19, 22],
#  [43, 50]]

# ─── OPÉRATIONS D'ALGÈBRE LINÉAIRE ───────────────────────────
# Déterminant
det_A = np.linalg.det(A)
print(f"Détérminant de A : {det_A:.2f}")  # -2.0

# Matrice inverse
A_inv = np.linalg.inv(A)
print("Inverse de A :")
print(A_inv)
# [[-2. ,  1. ],
#  [ 1.5, -0.5]]

# Vérification : A × A^-1 = I (matrice identité)
print(np.round(A @ A_inv, 10))
# [[1., 0.],
#  [0., 1.]]

# Valeurs et vecteurs propres
valeurs_propres, vecteurs_propres = np.linalg.eig(A)
print(f"Valeurs propres : {valeurs_propres}")

# Résolution de système linéaire : Ax = b
A = np.array([[3, 1], [1, 2]])
b = np.array([9, 8])
x = np.linalg.solve(A, b)
print(f"Solution : {x}")  # [2., 3.]
# Vérification : A @ x = b
print(np.allclose(A @ x, b))  # True

# Norme d'un vecteur
v = np.array([3, 4])
print(np.linalg.norm(v))    # 5.0  (norme L2 = √(3²+4²) = 5)
print(np.linalg.norm(v, 1)) # 7.0  (norme L1 = |3|+|4| = 7)

# ─── APPLICATION : RÉGRESSION LINÉAIRE MANUELLE ───────────────
# y = ax + b  -> trouver a et b
np.random.seed(42)
X = np.linspace(0, 10, 100)
y = 2.5 * X + 1 + np.random.randn(100)  # y = 2.5x + 1 + bruit

# Forme matricielle : [X, 1] × [a, b]^T = y
A_reg = np.column_stack([X, np.ones(len(X))])  # Ajouter colonne de 1s
coeffs, residuals, rank, sv = np.linalg.lstsq(A_reg, y, rcond=None)
print(f"Pente (a)    : {coeffs[0]:.4f}")  # ~2.5
print(f"Intercept (b): {coeffs[1]:.4f}")  # ~1.0
```

────────────────────────────────────────
13.4 COMPARAISONS ET TESTS LOGIQUES
────────────────────────────────────────

```python
import numpy as np

a = np.array([1, 2, 3, 4, 5])
b = np.array([3, 3, 3, 3, 3])

# Comparaisons élément par élément
print(a > b)    # [False, False, False, True, True]
print(a == b)   # [False, False, True, False, False]
print(a != b)   # [True, True, False, True, True]

# Opérations logiques sur arrays booléens
mask1 = a > 2   # [F, F, T, T, T]
mask2 = a < 5   # [T, T, T, T, F]
print(np.logical_and(mask1, mask2))   # [F, F, T, T, F]
print(np.logical_or(mask1, mask2))    # [T, T, T, T, T]
print(np.logical_not(mask1))          # [T, T, F, F, F]

# Tests globaux
print(np.any(a > 4))   # True  (au moins un élément > 4)
print(np.all(a > 0))   # True  (tous les éléments > 0)
print(np.all(a > 2))   # False (pas tous > 2)

# Comparaison de tableaux avec tolérance (flottants)
x = np.array([0.1 + 0.2])     # 0.30000000000000004 (erreur float !)
y = np.array([0.3])

print(x == y)             # [False]  <- problème d'arrondi float !
print(np.isclose(x, y))   # [True]   <- comparaison avec tolérance
print(np.allclose(x, y))  # True     <- tous les éléments proches

# np.where : condition ternaire vectorisée
arr = np.array([10, -5, 20, -3, 15])
resultat = np.where(arr > 0, arr, 0)  # Garder positif, remplacer négatif par 0
print(resultat)  # [10, 0, 20, 0, 15]

# np.where plus complexe
notes = np.array([15, 8, 12, 17, 9, 14])
mentions = np.where(notes >= 16, "TB",
           np.where(notes >= 14, "B",
           np.where(notes >= 12, "AB",
           np.where(notes >= 10, "P", "Ajourné"))))
print(mentions)  # ['B', 'Ajourné', 'AB', 'TB', 'Ajourné', 'B']
```

================================================================================
CHAPITRE 14 — BROADCASTING
================================================================================

────────────────────────────────────────
14.1 INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────

Le broadcasting est l'une des fonctionnalités les plus puissantes (et
les plus déroutantes) de NumPy. Il permet d'effectuer des opérations
entre tableaux de formes DIFFÉRENTES sans avoir à copier les données.

Règle du broadcasting :
  Deux dimensions sont compatibles si :
    1. Elles sont égales, OU
    2. L'une d'elles est 1

  NumPy "étire" virtuellement la dimension de taille 1 pour qu'elle
  corresponde à l'autre dimension. Aucune copie n'est faite en mémoire !

────────────────────────────────────────
14.2 EXEMPLES DE BROADCASTING
────────────────────────────────────────

```python
import numpy as np

# ─── CAS 1 : SCALAIRE + ARRAY ────────────────────────────────
arr = np.array([1, 2, 3, 4, 5])

result = arr + 10   # 10 est "étiré" pour correspondre à arr
# Conceptuellement : [1,2,3,4,5] + [10,10,10,10,10] = [11,12,13,14,15]
print(result)  # [11 12 13 14 15]

# ─── CAS 2 : VECTEUR LIGNE + VECTEUR COLONNE ─────────────────
ligne = np.array([1, 2, 3])          # Shape: (3,)
colonne = np.array([[10], [20], [30]])  # Shape: (3, 1)

result = ligne + colonne
# Shape résultat: (3, 3)
# ligne   : [[1,  2,  3],
#             [1,  2,  3],
#             [1,  2,  3]]  <- étiré verticalement
# colonne : [[10, 10, 10],
#             [20, 20, 20],
#             [30, 30, 30]]  <- étiré horizontalement
print(result)
# [[11, 12, 13],
#  [21, 22, 23],
#  [31, 32, 33]]

# ─── CAS 3 : MATRICE + VECTEUR ────────────────────────────────
M = np.array([
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
])  # Shape: (3, 3)

v = np.array([10, 20, 30])  # Shape: (3,) -> interprété comme (1, 3)

result = M + v
# v est étiré sur chaque ligne
print(result)
# [[11, 22, 33],
#  [14, 25, 36],
#  [17, 28, 39]]

# ─── CAS 4 : BROADCASTING IMPOSSIBLE ─────────────────────────
a = np.array([1, 2, 3])    # Shape: (3,)
b = np.array([1, 2])       # Shape: (2,)

# try: a + b  -> ValueError: shape mismatch (3 ≠ 2, ni l'un n'est 1)

# ─── RÈGLES VISUELLES DU BROADCASTING ──────────────────────────
# Pour vérifier si deux shapes sont compatibles,
# aligner les dimensions depuis la DROITE :
#
#   (3, 4) et (4,)    -> OK : aligner -> (3, 4) + (1, 4)
#   (3, 4) et (3,)    -> ERREUR : aligne ->(3,4) + (1,3) -> 4≠3
#   (3, 4) et (1,)    -> OK : (3, 4) + (1, 1)
#   (3, 4, 5) et (4, 5) -> OK
#   (3, 1, 5) et (1, 4, 5) -> OK -> (3, 4, 5)

# ─── APPLICATION PRATIQUE : NORMALISATION ────────────────────
data = np.array([
    [1, 200, 0.5],
    [2, 300, 0.8],
    [3, 150, 0.3],
    [4, 400, 0.9]
])  # 4 observations, 3 features

# Normalisation min-max par colonne
min_vals = data.min(axis=0)   # Shape: (3,)
max_vals = data.max(axis=0)   # Shape: (3,)

# Broadcasting : data (4,3) - min_vals (3,) -> OK (aligner à droite)
data_normalise = (data - min_vals) / (max_vals - min_vals)
print(data_normalise)
# Chaque colonne est maintenant dans [0, 1]

# Standardisation (Z-score) par colonne
mean = data.mean(axis=0)   # Shape: (3,)
std  = data.std(axis=0)    # Shape: (3,)
data_standardise = (data - mean) / std
# Chaque colonne a maintenant moyenne=0 et std=1

# ─── APPLICATION : DISTANCE EUCLIDIENNE ──────────────────────
# Calculer la distance entre tous les points d'un ensemble
points = np.array([
    [0, 0],
    [3, 4],
    [1, 1],
    [5, 0]
])  # Shape: (4, 2) — 4 points en 2D

# Broadcasting pour calculer toutes les distances d'un coup !
# points[:, np.newaxis, :] -> Shape: (4, 1, 2)
# points[np.newaxis, :, :] -> Shape: (1, 4, 2)
# Différences -> Shape: (4, 4, 2)
diffs = points[:, np.newaxis, :] - points[np.newaxis, :, :]
# Norme L2
distances = np.sqrt((diffs ** 2).sum(axis=2))
print(distances)
# [[0.   5.   1.41 5.  ]
#  [5.   0.   4.24 5.  ]
#  [1.41 4.24 0.   4.  ]
#  [5.   5.   4.   0.  ]]
```

────────────────────────────────────────
14.3 APPLICATIONS AVANCÉES
────────────────────────────────────────

```python
import numpy as np

# ─── TABLE DE MULTIPLICATION AVEC BROADCASTING ───────────────
a = np.arange(1, 11)   # [1, 2, ..., 10]   Shape: (10,)
b = a[:, np.newaxis]   # [[1],[2],...,[10]]  Shape: (10, 1)

table = a * b          # Broadcasting : (10,) × (10, 1) -> (10, 10)
print(table)

# ─── SIMULATION MONTE CARLO ─────────────────────────────────
# Estimer π avec des points aléatoires dans un carré
np.random.seed(42)
n_points = 1_000_000

# Générer n_points dans le carré [-1, 1] × [-1, 1]
points = np.random.uniform(-1, 1, size=(n_points, 2))  # Shape: (n, 2)

# Distance à l'origine (broadcasting interne)
distances = np.sqrt((points ** 2).sum(axis=1))  # Shape: (n,)

# Points dans le cercle unité
dans_cercle = distances <= 1

# Estimation de π
pi_estime = 4 * dans_cercle.sum() / n_points
print(f"π estimé  : {pi_estime:.6f}")
print(f"π réel    : {np.pi:.6f}")

# ─── OPÉRATIONS SUR DES SÉRIES TEMPORELLES ───────────────────
np.random.seed(42)
cours = 100 + np.cumsum(np.random.randn(252))  # 1 an de cours boursier

# Rendements journaliers
rendements = np.diff(cours) / cours[:-1]

# Moyenne mobile sur 20 jours
def moyenne_mobile(arr, fenetre):
    """Calcule la moyenne mobile avec NumPy (sans boucle)."""
    kernel = np.ones(fenetre) / fenetre
    return np.convolve(arr, kernel, mode='valid')

mm20 = moyenne_mobile(cours, 20)
mm50 = moyenne_mobile(cours, 50)

print(f"Rendement moyen  : {rendements.mean()*100:.3f}%/jour")
print(f"Volatilité annuelle: {rendements.std()*np.sqrt(252)*100:.1f}%")
```

────────────────────────────────────────
14.4 BONNES PRATIQUES NUMPY
────────────────────────────────────────

```python
# ─── RÈGLES D'OR NUMPY ────────────────────────────────────────

# RÈGLE 1 : Éviter les boucles Python sur des arrays NumPy
# MAUVAIS : ~100x plus lent
arr = np.arange(1_000_000)
resultat = []
for x in arr:
    resultat.append(x ** 2)

# BON : vectorisé
resultat = arr ** 2

# RÈGLE 2 : Preallocate les tableaux de sortie
n = 1_000_000
# MAUVAIS : append en boucle (reallocation constante)
result = []
for i in range(n):
    result.append(i * 2)

# BON : préallocation
result = np.empty(n)
for i in range(n):
    result[i] = i * 2

# ENCORE MIEUX : vectorisé pur
result = np.arange(n) * 2

# RÈGLE 3 : Fixer le seed pour la reproductibilité
np.random.seed(42)  # TOUJOURS au début de chaque analyse !
# Ou mieux (Python 3.11+) :
rng = np.random.default_rng(42)  # Generator moderne
arr = rng.random(1000)

# RÈGLE 4 : Vérifier les shapes avant les opérations
def safe_add(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    """Addition sécurisée avec vérification de compatibilité."""
    try:
        return a + b
    except ValueError as e:
        raise ValueError(
            f"Shapes incompatibles : {a.shape} et {b.shape}\n"
            f"Erreur NumPy : {e}"
        )

# RÈGLE 5 : Utiliser les dtypes appropriés
# Économiser la mémoire pour les grands datasets
data_float16 = arr.astype(np.float16)  # 2 octets vs 8 (économie de 75% !)
# Mais : précision réduite, risque d'overflow !

# Pour les images (0-255)
image = np.random.randint(0, 256, (1024, 1024, 3), dtype=np.uint8)
# uint8 = 3 MB vs float64 = 24 MB !
```

────────────────────────────────────────
14.5 EXERCICES PRATIQUES — PARTIE 3
────────────────────────────────────────

EXERCICES FACILES :
  1. Créez une matrice 5×5 avec des entiers aléatoires de 1 à 100
     et affichez le max de chaque ligne et le min de chaque colonne

  2. Créez un array de 100 valeurs uniformes entre 0 et 1,
     puis appliquez le masque pour garder uniquement les valeurs > 0.5

  3. Utilisez np.where pour remplacer les valeurs négatives d'un array
     par leur valeur absolue

EXERCICES INTERMÉDIAIRES :
  4. Implémentez la normalisation Z-score d'une matrice 100×5
     (chaque colonne doit avoir moy=0 et std=1) avec broadcasting

  5. Calculez la matrice de distance euclidienne entre 10 points
     en 3D en utilisant le broadcasting (sans boucles)

  6. Simulez 1000 lancers de 2 dés (6 faces) et calculez
     la distribution des sommes obtenues

EXERCICES AVANCÉS :
  7. Implémentez l'algorithme K-means (k=3) de zéro avec NumPy pur
     (sans scikit-learn) pour 200 points en 2D

  8. Calculez la corrélation de Pearson entre toutes les paires
     de colonnes d'une matrice 1000×10 en une seule expression

  9. Implémentez une convolution 2D (filtre de flou) sur une matrice
     d'image simulée avec NumPy (sans scipy)

────────────────────────────────────────
14.6 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────

CORRIGÉ EXERCICE 4 (Normalisation Z-score) :
```python
import numpy as np

np.random.seed(42)
data = np.random.randn(100, 5) * np.array([1, 5, 10, 0.5, 100])
# Chaque colonne a une échelle différente

def z_score_normaliser(data):
    """
    Normalise une matrice par colonnes (Z-score).
    Après normalisation : chaque colonne a moy=0, std=1.

    Shape data : (n_samples, n_features)
    """
    mean = data.mean(axis=0)   # Shape: (n_features,)
    std  = data.std(axis=0)    # Shape: (n_features,)

    # Vérifier qu'aucune colonne n'a std=0 (division par zéro)
    if np.any(std == 0):
        raise ValueError("Une ou plusieurs colonnes ont une variance nulle")

    # Broadcasting : (100, 5) - (5,) -> (100, 5) — automatique !
    return (data - mean) / std

data_norm = z_score_normaliser(data)

# Vérification
print("Avant normalisation :")
print(f"Moyennes : {data.mean(axis=0).round(2)}")
print(f"Std      : {data.std(axis=0).round(2)}")

print("\nAprès normalisation :")
print(f"Moyennes : {data_norm.mean(axis=0).round(10)}")   # ~0 (erreur numérique)
print(f"Std      : {data_norm.std(axis=0).round(10)}")    # ~1.0
```

CORRIGÉ EXERCICE 6 (Simulation dés) :
```python
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
n_lancers = 100_000

# Simuler 2 dés pour n_lancers
# Shape : (n_lancers, 2)
des = np.random.randint(1, 7, size=(n_lancers, 2))

# Sommes de chaque lancer
sommes = des.sum(axis=1)  # Shape: (n_lancers,)

# Distribution théorique
prob_theorique = {
    2: 1/36, 3: 2/36, 4: 3/36, 5: 4/36, 6: 5/36, 7: 6/36,
    8: 5/36, 9: 4/36, 10: 3/36, 11: 2/36, 12: 1/36
}

# Distribution observée
for somme in range(2, 13):
    frequence = (sommes == somme).mean()
    theorique = prob_theorique[somme]
    print(f"Somme {somme:2d} : {frequence:.3f} (théorie: {theorique:.3f})")

print(f"\nSomme la plus fréquente : {np.bincount(sommes).argmax()}")  # 7
```

================================================================================
RÉSUMÉ DE LA PARTIE 3
================================================================================

Dans cette partie, nous avons maîtrisé NumPy :

  [OK] Pourquoi NumPy est 50-500x plus rapide que Python pur
  [OK] Création de tableaux (arange, linspace, zeros, ones, random)
  [OK] Indexage, slicing et indexage booléen (masques)
  [OK] Reshape, transpose, concatenation
  [OK] Opérations vectorisées et ufuncs
  [OK] Statistiques descriptives par axe
  [OK] Algèbre linéaire (dot product, inverse, résolution de systèmes)
  [OK] Broadcasting : opérations sur des tableaux de formes différentes
  [OK] Applications : normalisation, simulation Monte Carlo, séries temporelles

PROCHAINE ÉTAPE :
  Partie 4 — Pandas ultra-détaillé (DataFrame, Indexing, GroupBy, Merge, Pivot)

================================================================================
FIN DE LA PARTIE 3
================================================================================

================================================================================
GUIDE COMPLET D'ANALYSE DE DONNÉES AVEC PYTHON
PARTIE 4 — PANDAS ULTRA-DÉTAILLÉ
================================================================================
Chapitres : 15-21 | Introduction, DataFrame, Indexing, Filtering,
            GroupBy, Merge, Pivot
================================================================================

TABLE DES MATIÈRES — PARTIE 4
──────────────────────────────
Chapitre 15 : Introduction à Pandas
Chapitre 16 : DataFrame — Création et Exploration
Chapitre 17 : Indexing avancé
Chapitre 18 : Filtering (Filtrage)
Chapitre 19 : GroupBy — Agrégations par groupe
Chapitre 20 : Merge, Join, Concat
Chapitre 21 : Pivot Tables et Crosstab

================================================================================
CHAPITRE 15 — INTRODUCTION À PANDAS
================================================================================

────────────────────────────────────────
15.1 QU'EST-CE QUE PANDAS ?
────────────────────────────────────────

Pandas (Python Data Analysis Library) est LA bibliothèque pour manipuler
des données tabulaires en Python. Construite sur NumPy, elle ajoute :

  - DataFrame : tableau 2D avec étiquettes (comme Excel, mais en Python)
  - Series    : colonne 1D avec étiquettes
  - Opérations SQL-like : groupby, merge, pivot...
  - Gestion native des valeurs manquantes (NaN)
  - Support de nombreux formats : CSV, Excel, JSON, SQL, Parquet...

Analogie Excel :
  Excel Classeur  <--> Pandas    (ensemble de données)
  Excel Feuille   <--> DataFrame (table 2D)
  Excel Colonne   <--> Series    (colonne étiquetée)
  Excel Cellule   <--> Valeur scalaire

Analogie SQL :
  SQL Table  <--> DataFrame
  SQL SELECT <--> df[colonnes]
  SQL WHERE  <--> df[condition]
  SQL GROUP BY <--> df.groupby()
  SQL JOIN   <--> pd.merge()
  SQL ORDER BY <--> df.sort_values()

────────────────────────────────────────
15.2 SERIES VS DATAFRAME
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# ─── SERIES : TABLEAU 1D ÉTIQUETÉ ────────────────────────────
# Une Series = un index + des valeurs

# Création depuis une liste
s1 = pd.Series([10, 20, 30, 40, 50])
print(s1)
# 0    10
# 1    20
# 2    30
# 3    40
# 4    50
# dtype: int64

# Index personnalisé
s2 = pd.Series(
    [10, 20, 30, 40, 50],
    index=["Jan", "Fev", "Mar", "Avr", "Mai"]
)
print(s2["Mar"])  # 30

# Depuis un dictionnaire
s3 = pd.Series({"Paris": 2161, "Lyon": 515, "Marseille": 861})
# Paris        2161
# Lyon          515
# Marseille     861
# dtype: int64

# Accès
print(s3["Paris"])         # 2161
print(s3[["Paris", "Lyon"]])  # 2 villes
print(s3[s3 > 600])        # Masque booléen

# Attributs
print(s3.values)   # [2161, 515, 861]  (numpy array)
print(s3.index)    # Index(['Paris', 'Lyon', 'Marseille'])
print(s3.dtype)    # int64
print(s3.name)     # None (peut être nommé)

# Opérations vectorisées
print(s3 * 1000)   # En milliers d'habitants
print(s3 / s3.max())  # Proportion par rapport au maximum

# ─── DATAFRAME : TABLEAU 2D ÉTIQUETÉ ─────────────────────────
# Un DataFrame = index de lignes + colonnes nommées + données

# Création depuis dictionnaire (le plus courant)
df = pd.DataFrame({
    "ville":    ["Paris", "Lyon", "Marseille", "Toulouse"],
    "region":   ["IDF", "ARA", "PACA", "OCC"],
    "pop":      [2161000, 515695, 861635, 471941],
    "superficie": [105.4, 47.9, 240.6, 118.3]
})

print(df)
#       ville region      pop  superficie
# 0     Paris    IDF  2161000       105.4
# 1      Lyon    ARA   515695        47.9
# 2 Marseille   PACA   861635       240.6
# 3  Toulouse    OCC   471941       118.3

print(df.shape)     # (4, 4)
print(df.dtypes)
# ville          object
# region         object
# pop             int64
# superficie    float64

print(df.index)    # RangeIndex(start=0, stop=4, step=1)
print(df.columns)  # Index(['ville', 'region', 'pop', 'superficie'])
```

================================================================================
CHAPITRE 16 — DATAFRAME : CRÉATION ET EXPLORATION
================================================================================

────────────────────────────────────────
16.1 CHARGEMENT DE DONNÉES
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# ─── GÉNÉRATION D'UN DATASET RÉALISTE ────────────────────────
# On va créer un dataset de ventes immobilières fictif
np.random.seed(42)
n = 500

df = pd.DataFrame({
    "id":           range(1, n+1),
    "ville":        np.random.choice(
                        ["Paris", "Lyon", "Marseille", "Toulouse", "Nice"],
                        n, p=[0.3, 0.25, 0.2, 0.15, 0.1]
                    ),
    "type_bien":    np.random.choice(["Appartement", "Maison", "Studio"], n, p=[0.5, 0.3, 0.2]),
    "surface_m2":   np.random.normal(80, 30, n).clip(15, 300).round(1),
    "nb_pieces":    np.random.randint(1, 8, n),
    "prix_euros":   np.random.lognormal(12, 0.8, n).round(-3),
    "annee_constr": np.random.randint(1900, 2023, n),
    "dpe":          np.random.choice(["A", "B", "C", "D", "E", "F", "G"], n,
                                     p=[0.05, 0.10, 0.20, 0.25, 0.20, 0.15, 0.05]),
    "date_vente":   pd.date_range("2022-01-01", periods=n, freq="D")[:n],
    "avec_garage":  np.random.choice([True, False], n, p=[0.4, 0.6]),
})

# Ajouter des NaN réalistes
df.loc[np.random.choice(n, 30, replace=False), "prix_euros"] = np.nan
df.loc[np.random.choice(n, 20, replace=False), "annee_constr"] = np.nan

# ─── EXPLORATION INITIALE ─────────────────────────────────────
# ÉTAPE 1 : Vue d'ensemble
print("=== SHAPE ===")
print(df.shape)           # (500, 10)

print("\n=== PREMIÈRES LIGNES ===")
print(df.head(5))         # 5 premières lignes (défaut = 5)
print(df.head(10))        # 10 premières lignes

print("\n=== DERNIÈRES LIGNES ===")
print(df.tail(5))         # 5 dernières lignes

print("\n=== ÉCHANTILLON ALÉATOIRE ===")
print(df.sample(5, random_state=42))  # 5 lignes aléatoires

# ÉTAPE 2 : Types et valeurs manquantes
print("\n=== INFOS COMPLÈTES ===")
df.info()
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 500 entries, 0 to 499
# Data columns (total 10 columns):
#  #   Column       Non-Null Count  Dtype
# ---  ------       --------------  -----
#  0   id           500 non-null    int64
#  1   ville        500 non-null    object
#  2   type_bien    500 non-null    object
#  3   surface_m2   500 non-null    float64
#  4   nb_pieces    500 non-null    int64
#  5   prix_euros   470 non-null    float64    <- 30 NaN !
#  6   annee_constr 480 non-null    float64    <- 20 NaN !
#  7   dpe          500 non-null    object
#  8   date_vente   500 non-null    datetime64[ns]
#  9   avec_garage  500 non-null    bool

# ÉTAPE 3 : Statistiques descriptives
print("\n=== STATISTIQUES ===")
print(df.describe())
# Affiche pour chaque colonne numérique :
# count, mean, std, min, 25%, 50%, 75%, max

# Statistiques pour colonnes texte aussi
print(df.describe(include="all"))
# Pour les object : count, unique, top (valeur la plus fréquente), freq

# ÉTAPE 4 : Valeurs manquantes
print("\n=== VALEURS MANQUANTES ===")
print(df.isnull().sum())
# id              0
# ville           0
# prix_euros     30
# annee_constr   20

# Pourcentage
print((df.isnull().sum() / len(df) * 100).round(2))

# ÉTAPE 5 : Cardinalité des colonnes catégorielles
print("\n=== VALEURS UNIQUES ===")
print(df["ville"].nunique())             # 5 villes
print(df["ville"].value_counts())        # Fréquences
print(df["dpe"].value_counts(normalize=True).round(3))  # Proportions
```

────────────────────────────────────────
16.2 MANIPULATION DES COLONNES
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# Suite du dataset immobilier
# (Recréez df depuis le chapitre précédent)

# ─── SÉLECTIONNER DES COLONNES ────────────────────────────────
# Une colonne -> Series
col_prix = df["prix_euros"]      # Méthode 1 (recommandée)
col_prix = df.prix_euros          # Méthode 2 (déconseillée si nom avec espace)

# Plusieurs colonnes -> DataFrame
df_subset = df[["ville", "surface_m2", "prix_euros"]]

# ─── CRÉER DE NOUVELLES COLONNES ─────────────────────────────
# Méthode 1 : assignation directe
df["prix_m2"] = df["prix_euros"] / df["surface_m2"]

# Méthode 2 : assign (chaînable, immutable)
df = df.assign(
    age_bien   = 2024 - df["annee_constr"],
    prix_m2    = df["prix_euros"] / df["surface_m2"],
    est_recent = df["annee_constr"] >= 2000
)

# Méthode 3 : apply avec fonction
def categorie_prix(prix):
    if pd.isna(prix):
        return None
    elif prix < 100_000:
        return "Budget"
    elif prix < 300_000:
        return "Moyen"
    elif prix < 600_000:
        return "Haut"
    else:
        return "Luxe"

df["categorie_prix"] = df["prix_euros"].apply(categorie_prix)

# Méthode 4 : np.where (plus rapide qu'apply)
df["est_grand"] = np.where(df["surface_m2"] >= 100, True, False)

# Méthode 5 : pd.cut (discrétiser une variable continue)
df["tranche_surface"] = pd.cut(
    df["surface_m2"],
    bins=[0, 30, 60, 100, 150, 300],
    labels=["Micro", "Petit", "Moyen", "Grand", "Très grand"]
)

# Méthode 6 : pd.qcut (quantiles égaux)
df["quartile_prix"] = pd.qcut(
    df["prix_euros"].dropna(),  # Exclure NaN
    q=4,
    labels=["Q1", "Q2", "Q3", "Q4"]
)

# ─── RENOMMER DES COLONNES ─────────────────────────────────────
df = df.rename(columns={
    "surface_m2":  "surface",
    "prix_euros":  "prix",
    "annee_constr": "annee"
})

# Renommer toutes les colonnes
df.columns = [col.lower().replace(" ", "_") for col in df.columns]

# ─── SUPPRIMER DES COLONNES ───────────────────────────────────
df = df.drop(columns=["id"])                  # Supprimer par nom
df = df.drop(columns=["col1", "col2"])         # Plusieurs colonnes
df.drop(columns=["col"], inplace=True)         # En place (déconseillé)

# ─── RÉORGANISER DES COLONNES ─────────────────────────────────
nouvel_ordre = ["ville", "type_bien", "surface", "nb_pieces",
                "prix", "prix_m2", "dpe", "date_vente"]
df = df[nouvel_ordre]
```

────────────────────────────────────────
16.3 APPLY, MAP, APPLYMAP
────────────────────────────────────────

```python
# ─── apply : Appliquer une fonction sur colonnes ou lignes ────
# apply sur une colonne (Series)
df["surface_log"] = df["surface"].apply(np.log)

# apply sur toutes les lignes (axis=1)
def calculer_score(row):
    """Score composite basé sur plusieurs colonnes."""
    score = 0
    if row["type_bien"] == "Maison":
        score += 20
    score += min(row["surface"] / 10, 30)  # Max 30 points pour surface
    if row["avec_garage"]:
        score += 10
    # Bonus DPE
    dpe_bonus = {"A": 25, "B": 20, "C": 15, "D": 10, "E": 5, "F": 0, "G": -5}
    score += dpe_bonus.get(row["dpe"], 0)
    return score

df["score_bien"] = df.apply(calculer_score, axis=1)

# ATTENTION : apply est lent sur de grandes données
# Préférer les opérations vectorisées quand possible

# ─── map : Transformer les valeurs d'une Series ──────────────
# Via dictionnaire
mapping_region = {
    "Paris": "Île-de-France",
    "Lyon": "Auvergne-Rhône-Alpes",
    "Marseille": "Provence-Alpes-Côte d'Azur",
    "Toulouse": "Occitanie",
    "Nice": "Provence-Alpes-Côte d'Azur"
}
df["region"] = df["ville"].map(mapping_region)
# Valeurs non mappées -> NaN

# Via fonction
df["prix_milliers"] = df["prix"].map(lambda x: round(x/1000, 1) if pd.notna(x) else None)

# ─── OPÉRATIONS STRING VECTORISÉES (.str) ────────────────────
# Éviter apply pour les transformations textuelles simples !
df["ville_upper"] = df["ville"].str.upper()
df["ville_lower"] = df["ville"].str.lower()
df["premiere_lettre"] = df["ville"].str[0]
df["ville_contient_a"] = df["ville"].str.contains("a", case=False)
df["ville_sans_espaces"] = df["ville"].str.strip()
df["parties_ville"] = df["ville"].str.split("-")  # Series de listes
df["longueur_nom"] = df["ville"].str.len()

# Extraction regex
df["code_dpe"] = df["dpe"].str.extract(r'([A-G])')  # Extraire lettre
```

================================================================================
CHAPITRE 17 — INDEXING AVANCÉ
================================================================================

────────────────────────────────────────
17.1 .LOC ET .ILOC
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# Dataset avec index personnalisé
df = pd.DataFrame({
    "ville":   ["Paris", "Lyon", "Marseille", "Toulouse"],
    "pop":     [2161, 515, 861, 471],
    "surface": [105.4, 47.9, 240.6, 118.3]
}, index=["P", "L", "M", "T"])  # Index alphabétique

# ─── .loc : ACCÈS PAR ÉTIQUETTES (LABELS) ─────────────────────
# Syntaxe : df.loc[lignes, colonnes]
# INCLUSIF des deux côtés !

# Ligne unique
print(df.loc["P"])          # Ligne Paris (Series)
print(df.loc["P", "pop"])   # Valeur unique : 2161

# Plusieurs lignes
print(df.loc[["P", "M"]])   # Paris et Marseille

# Slice de lignes (ATTENTION : inclusif !)
print(df.loc["L":"T"])      # De Lyon à Toulouse inclus (L, M, T)

# Ligne + colonnes spécifiques
print(df.loc["P", ["ville", "pop"]])

# Condition booléenne avec loc
print(df.loc[df["pop"] > 700])     # Villes > 700k habitants

# Modification avec loc
df.loc["P", "pop"] = 2200          # Modifier Paris
df.loc[df["pop"] > 700, "grande_ville"] = True  # Nouvelle colonne conditionnelle

# ─── .iloc : ACCÈS PAR POSITION (INTEGERS) ────────────────────
# Syntaxe : df.iloc[lignes, colonnes]
# Comme NumPy : EXCLUSIF côté droit !

# Ligne unique (par position)
print(df.iloc[0])         # Première ligne
print(df.iloc[-1])        # Dernière ligne
print(df.iloc[0, 1])      # Ligne 0, colonne 1 -> 2161

# Slice de lignes
print(df.iloc[0:3])       # Lignes 0, 1, 2 (exclusif en fin !)
print(df.iloc[::2])       # Lignes paires : 0, 2

# Slice lignes + colonnes
print(df.iloc[0:2, 1:3])  # Lignes 0-1, colonnes 1-2

# ─── .at ET .iat : ACCÈS RAPIDE À UNE VALEUR UNIQUE ──────────
# Plus rapides que loc/iloc pour une seule valeur
df.at["P", "pop"]   # Comme loc mais plus rapide
df.iat[0, 1]        # Comme iloc mais plus rapide

# ─── QUAND UTILISER LOC VS ILOC ? ────────────────────────────
# .loc  -> quand vous travaillez avec des étiquettes connues
#          ex: df.loc["2024-01-15"]  (index date)
# .iloc -> quand vous travaillez avec des positions (boucles, ML)
#          ex: df.iloc[train_indices]

# ─── PIÈGES COURANTS ─────────────────────────────────────────
# Problème : ChainedIndexing (anti-pattern dangereux)
# MAUVAIS : crée une copie temporaire -> modification ignorée !
df["pop"][df["pop"] > 700] = 999  # DANGER : SettingWithCopyWarning

# BON : utiliser toujours .loc pour modifier
df.loc[df["pop"] > 700, "pop"] = 999
```

────────────────────────────────────────
17.2 INDEX AVANCÉ : DATES ET MULTIINDEX
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# ─── INDEX DATETIMEINDEX ──────────────────────────────────────
# Parfait pour les séries temporelles

dates = pd.date_range("2024-01-01", "2024-12-31", freq="D")
ts = pd.Series(
    np.random.randn(len(dates)),
    index=dates,
    name="temperature"
)

# Accès par date (très intuitif !)
print(ts["2024-03-15"])         # Un jour spécifique
print(ts["2024-03"])            # Tout le mois de mars
print(ts["2024-Q1"])            # 1er trimestre
print(ts["2024-01":"2024-03"])  # Plage de mois

# Opérations temporelles
print(ts.resample("ME").mean())    # Moyenne mensuelle
print(ts.resample("W").sum())     # Somme hebdomadaire
print(ts.resample("Q").std())     # Écart-type trimestriel

# Fréquences disponibles :
# "D" = jour, "W" = semaine, "ME" = fin de mois, "MS" = début de mois
# "Q" = trimestre, "A" = année, "H" = heure, "T" = minute

# ─── MULTIINDEX : INDEX HIÉRARCHIQUE ──────────────────────────
# Pour des données à plusieurs niveaux (pays -> ville, année -> mois...)

# Création
arrays = [
    ["2022", "2022", "2022", "2023", "2023", "2023"],
    ["Q1",   "Q2",   "Q3",   "Q1",   "Q2",   "Q3"]
]
index = pd.MultiIndex.from_arrays(arrays, names=["annee", "trimestre"])
ventes = pd.Series([100, 120, 110, 130, 150, 140], index=index, name="ventes")

print(ventes)
# annee  trimestre
# 2022   Q1           100
#        Q2           120
#        Q3           110
# 2023   Q1           130
#        Q2           150
#        Q3           140

# Accès multiindex
print(ventes["2022"])          # Tout 2022
print(ventes["2022"]["Q1"])    # 2022 Q1
print(ventes.loc["2022", "Q2"])  # 2022 Q2

# DataFrame avec MultiIndex
df_multi = pd.DataFrame({
    "CA":       [100, 120, 130, 150],
    "couts":    [60, 70, 75, 85]
}, index=pd.MultiIndex.from_product(
    [["Nord", "Sud"], ["2022", "2023"]],
    names=["region", "annee"]
))

# Accès
print(df_multi.loc["Nord"])               # Toute la région Nord
print(df_multi.loc[("Nord", "2022")])     # Nord, 2022 spécifiquement
print(df_multi.xs("2022", level="annee")) # Toute l'année 2022, toutes régions

# Réorganiser les niveaux
df_multi = df_multi.swaplevel()           # Inverser ordre des niveaux
df_multi = df_multi.sort_index()          # Toujours trier après swap !

# Aplatir le MultiIndex
df_plat = df_multi.reset_index()          # MultiIndex -> colonnes normales
```

================================================================================
CHAPITRE 18 — FILTERING (FILTRAGE)
================================================================================

────────────────────────────────────────
18.1 FILTRAGE BOOLÉEN
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    "nom":      ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank"],
    "age":      [25, 32, 28, 45, 31, 22],
    "dept":     ["IT", "Finance", "IT", "RH", "Finance", "IT"],
    "salaire":  [45000, 62000, 48000, 55000, 71000, 38000],
    "anciennete": [2, 8, 4, 15, 6, 1],
    "actif":    [True, True, False, True, True, False]
})

# ─── FILTRES SIMPLES ─────────────────────────────────────────
# Condition unique
print(df[df["age"] > 30])                    # Âge > 30
print(df[df["dept"] == "IT"])               # Département IT
print(df[df["actif"]])                       # Actifs (True)
print(df[~df["actif"]])                      # Non-actifs (NOT)

# ─── FILTRES COMPOSÉS ─────────────────────────────────────────
# Opérateurs : & (ET), | (OU), ~ (NON)
# OBLIGATOIRE : parenthèses autour de chaque condition !

# ET : employés IT de plus de 25 ans
print(df[(df["dept"] == "IT") & (df["age"] > 25)])

# OU : IT ou Finance
print(df[(df["dept"] == "IT") | (df["dept"] == "Finance")])

# Combinaison complexe
cond = (
    (df["age"] > 25) &
    (df["salaire"] >= 50000) &
    (df["dept"].isin(["IT", "Finance"])) &
    (df["actif"])
)
print(df[cond])

# ─── .ISIN() : VALEUR DANS UNE LISTE ─────────────────────────
depts_voulus = ["IT", "Finance"]
print(df[df["dept"].isin(depts_voulus)])

# Inverse (NOT IN)
print(df[~df["dept"].isin(["RH"])])

# ─── .BETWEEN() : VALEUR DANS UN INTERVALLE ──────────────────
print(df[df["age"].between(25, 35)])  # 25 ≤ age ≤ 35 (inclus des deux côtés)
print(df[df["salaire"].between(40000, 65000)])

# ─── .STR.CONTAINS() : FILTRE SUR TEXTE ─────────────────────
df_villes = pd.DataFrame({
    "description": ["Appartement Paris", "Maison Lyon", "Studio Paris 16e", "Villa Nice"]
})
print(df_villes[df_villes["description"].str.contains("Paris")])
print(df_villes[df_villes["description"].str.contains("^Appart", regex=True)])

# ─── .QUERY() : SYNTAXE SQL-LIKE ─────────────────────────────
# Plus lisible pour des filtres complexes
print(df.query("age > 30 and salaire > 50000"))
print(df.query("dept in ['IT', 'Finance'] and actif == True"))
print(df.query("salaire.between(40000, 70000)"))

# Variables dans query (utiliser @)
age_min = 28
print(df.query("age >= @age_min and dept == 'IT'"))

# ─── FILTRAGE DES VALEURS MANQUANTES ─────────────────────────
df_avec_nan = df.copy()
df_avec_nan.loc[[0, 2], "salaire"] = np.nan

# Lignes avec NaN dans une colonne
print(df_avec_nan[df_avec_nan["salaire"].isna()])

# Lignes SANS NaN dans une colonne
print(df_avec_nan[df_avec_nan["salaire"].notna()])

# Supprimer toutes les lignes avec au moins 1 NaN
print(df_avec_nan.dropna())

# Supprimer les lignes avec NaN dans des colonnes spécifiques
print(df_avec_nan.dropna(subset=["salaire", "age"]))

# ─── SÉLECTION DES MEILLEURES / PIRES LIGNES ─────────────────
print(df.nlargest(3, "salaire"))   # 3 salaires les plus élevés
print(df.nsmallest(2, "age"))      # 2 employés les plus jeunes
```

────────────────────────────────────────
18.2 FILTRAGE SUR LES TYPES ET DTYPES
────────────────────────────────────────

```python
# ─── SÉLECTION PAR TYPE DE DONNÉES ────────────────────────────
df_mix = pd.DataFrame({
    "A": [1, 2, 3],
    "B": [1.5, 2.5, 3.5],
    "C": ["x", "y", "z"],
    "D": [True, False, True]
})

# Sélectionner uniquement les colonnes numériques
df_num   = df_mix.select_dtypes(include=[np.number])       # A et B
df_float = df_mix.select_dtypes(include=["float64"])       # B seulement
df_text  = df_mix.select_dtypes(include=["object"])        # C seulement
df_excl  = df_mix.select_dtypes(exclude=["bool", "object"])  # A et B

# Utilisation typique : appliquer une opération sur toutes les colonnes numériques
numeric_cols = df_mix.select_dtypes(include=[np.number]).columns
df_mix[numeric_cols] = df_mix[numeric_cols] * 2  # Doubler toutes les valeurs numériques
```

================================================================================
CHAPITRE 19 — GROUPBY : AGRÉGATIONS PAR GROUPE
================================================================================

────────────────────────────────────────
19.1 INTRODUCTION AU GROUPBY
────────────────────────────────────────

GroupBy suit le paradigme Split-Apply-Combine :

  SPLIT   : Diviser le DataFrame en groupes selon une ou plusieurs colonnes
  APPLY   : Appliquer une fonction à chaque groupe
  COMBINE : Combiner les résultats en un nouveau DataFrame

```
      DataFrame original
            v
      Split par "département"
      ┌────────┐ ┌─────────┐ ┌────┐
      │   IT   │ │ Finance │ │ RH │
      └────────┘ └─────────┘ └────┘
            v
      Apply : mean(salaire)
      ┌──────┐ ┌──────┐ ┌──────┐
      │43667 │ │66500 │ │55000 │
      └──────┘ └──────┘ └──────┘
            v
      Combine : DataFrame résultat
      dept     salaire_moyen
      Finance  66500
      IT       43667
      RH       55000
```

────────────────────────────────────────
19.2 GROUPBY DE BASE
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    "nom":        [f"Emp{i}" for i in range(20)],
    "dept":       np.random.choice(["IT", "Finance", "RH", "Marketing"], 20),
    "ville":      np.random.choice(["Paris", "Lyon", "Marseille"], 20),
    "salaire":    np.random.randint(35000, 90000, 20),
    "anciennete": np.random.randint(1, 20, 20),
    "score":      np.random.uniform(1, 5, 20).round(1)
})

# ─── GROUPBY + AGRÉGATION SIMPLE ─────────────────────────────
# Salaire moyen par département
print(df.groupby("dept")["salaire"].mean())
# dept
# Finance      XX.XX
# IT           XX.XX
# Marketing    XX.XX
# RH           XX.XX

# Plusieurs statistiques à la fois
print(df.groupby("dept")["salaire"].agg(["mean", "median", "std", "count"]))
# dept          mean  median    std  count
# Finance    57000.0  56000.0  ...     5
# IT         65000.0  ...

# ─── GROUPBY SUR PLUSIEURS COLONNES ──────────────────────────
# Salaire moyen par département ET par ville
result = df.groupby(["dept", "ville"])["salaire"].mean().unstack()
print(result)
# ville     Lyon  Marseille    Paris
# dept
# Finance   ...      ...       ...
# IT        ...      ...       ...

# ─── .agg() : AGRÉGATIONS MULTIPLES ──────────────────────────
# Différentes statistiques pour différentes colonnes
result = df.groupby("dept").agg(
    n_employes    = ("nom", "count"),               # Compter les noms
    salaire_moyen = ("salaire", "mean"),             # Moyenne salaire
    salaire_max   = ("salaire", "max"),              # Max salaire
    anciennete_med= ("anciennete", "median"),        # Médiane ancienneté
    score_moy     = ("score", "mean"),               # Moyenne score
).round(2)

print(result)

# ─── FONCTIONS D'AGRÉGATION DISPONIBLES ──────────────────────
# count     : Nombre de valeurs non-null
# sum       : Somme
# mean      : Moyenne arithmétique
# median    : Médiane
# std       : Écart-type (ddof=1)
# var       : Variance
# min       : Minimum
# max       : Maximum
# first     : Première valeur
# last      : Dernière valeur
# nunique   : Nombre de valeurs uniques
# mode      : Mode (valeur la plus fréquente)

# ─── GROUPBY AVEC FONCTIONS PERSONNALISÉES ────────────────────
def iqr(serie):
    """Calcule l'IQR (interquartile range)."""
    return serie.quantile(0.75) - serie.quantile(0.25)

def coeff_variation(serie):
    """Coefficient de variation = std/mean."""
    return serie.std() / serie.mean() if serie.mean() != 0 else 0

result_custom = df.groupby("dept")["salaire"].agg(
    ["mean", "std", iqr, coeff_variation]
).round(2)
print(result_custom)

# ─── SIZE ET COUNT ────────────────────────────────────────────
# .size() : compte toutes les lignes (incluant NaN)
print(df.groupby("dept").size())

# .count() : compte les valeurs non-NaN colonne par colonne
print(df.groupby("dept").count())
```

────────────────────────────────────────
19.3 GROUPBY AVANCÉ
────────────────────────────────────────

```python
# ─── TRANSFORM : CALCUL PAR GROUPE SANS RÉDUIRE ──────────────
# transform retourne une Series de MÊME TAILLE que le DataFrame original
# Très utile pour ajouter des colonnes calculées par groupe

# Salaire moyen du département pour chaque employé
df["salaire_moy_dept"] = df.groupby("dept")["salaire"].transform("mean")

# Rang de l'employé dans son département
df["rang_dans_dept"] = df.groupby("dept")["salaire"].transform(
    lambda x: x.rank(ascending=False)
)

# Déviation du salaire par rapport à la moyenne du groupe
df["deviation_salaire"] = df["salaire"] - df["salaire_moy_dept"]

# Standardisation par groupe (Z-score dans chaque département)
df["salaire_zscore"] = df.groupby("dept")["salaire"].transform(
    lambda x: (x - x.mean()) / x.std()
)

# ─── FILTER : GARDER SEULEMENT CERTAINS GROUPES ──────────────
# Garder seulement les départements avec plus de 4 employés
df_grands_depts = df.groupby("dept").filter(lambda x: len(x) > 4)

# Garder les départements avec salaire moyen > 55000
df_bien_payes = df.groupby("dept").filter(lambda x: x["salaire"].mean() > 55000)

# ─── APPLY : FONCTION COMPLEXE PAR GROUPE ────────────────────
def rapport_groupe(groupe_df):
    """Génère un rapport pour un groupe."""
    return pd.Series({
        "n":           len(groupe_df),
        "moy_sal":     groupe_df["salaire"].mean(),
        "top_earner":  groupe_df.loc[groupe_df["salaire"].idxmax(), "nom"],
        "sal_pct_75":  groupe_df["salaire"].quantile(0.75),
    })

rapport = df.groupby("dept").apply(rapport_groupe)
print(rapport)

# ─── CUMULATIVE OPERATIONS ────────────────────────────────────
# Somme cumulative par groupe (utile pour time series)
df_sorted = df.sort_values(["dept", "anciennete"])
df_sorted["cumul_anciennete"] = df_sorted.groupby("dept")["anciennete"].cumsum()

# ─── BOUCLE SUR LES GROUPES ───────────────────────────────────
# Parfois utile pour des rapports par groupe
for nom_dept, groupe in df.groupby("dept"):
    print(f"\n=== Département : {nom_dept} ({len(groupe)} employés) ===")
    print(groupe[["nom", "salaire"]].sort_values("salaire", ascending=False))

# ─── NAMED AGGREGATION (Pandas 0.25+) ─────────────────────────
# Syntaxe la plus lisible pour les agrégations
result = df.groupby("dept").agg(
    nb_employes    = pd.NamedAgg(column="nom", aggfunc="count"),
    sal_moyen      = pd.NamedAgg(column="salaire", aggfunc="mean"),
    sal_total      = pd.NamedAgg(column="salaire", aggfunc="sum"),
    anc_moyenne    = pd.NamedAgg(column="anciennete", aggfunc="mean"),
)
```

================================================================================
CHAPITRE 20 — MERGE, JOIN ET CONCAT
================================================================================

────────────────────────────────────────
20.1 CONCAT : EMPILER DES DATAFRAMES
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

# ─── pd.concat : CONCATÉNATION SIMPLE ────────────────────────
df1 = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
df2 = pd.DataFrame({"A": [5, 6], "B": [7, 8]})
df3 = pd.DataFrame({"A": [9], "C": [10]})  # Colonne différente !

# Concaténer verticalement (par défaut, axis=0)
df_concat = pd.concat([df1, df2])
print(df_concat)
#    A  B
# 0  1  3
# 1  2  4
# 0  5  7  <- Index dupliqué !
# 1  6  8

# Réinitialiser l'index (recommandé !)
df_concat = pd.concat([df1, df2], ignore_index=True)
#    A  B
# 0  1  3
# 1  2  4
# 2  5  7
# 3  6  8

# Avec colonnes différentes (NaN pour les colonnes manquantes)
df_all = pd.concat([df1, df3], ignore_index=True)
#    A    B     C
# 0  1  3.0   NaN
# 1  2  4.0   NaN
# 2  9  NaN  10.0

# Concaténer horizontalement (axis=1)
df_h = pd.concat([df1, df2], axis=1)
#    A  B  A  B  <- Colonnes dupliquées !
# 0  1  3  5  7
# 1  2  4  6  8

# Cas réel : fusionner des fichiers mensuels
# fichiers = ["jan.csv", "feb.csv", "mar.csv"]
# dfs = [pd.read_csv(f) for f in fichiers]
# df_annuel = pd.concat(dfs, ignore_index=True)
```

────────────────────────────────────────
20.2 MERGE : JOINTURES SQL
────────────────────────────────────────

```python
import pandas as pd

# ─── DATASETS D'EXEMPLE ───────────────────────────────────────
clients = pd.DataFrame({
    "client_id":  [1, 2, 3, 4, 5],
    "nom":        ["Alice", "Bob", "Carol", "Dave", "Eve"],
    "ville":      ["Paris", "Lyon", "Paris", "Marseille", "Lyon"]
})

commandes = pd.DataFrame({
    "commande_id": [101, 102, 103, 104, 105, 106],
    "client_id":   [1, 2, 1, 3, 6, 2],    # client_id 6 n'existe pas !
    "produit":     ["Laptop", "Phone", "Tablet", "Headphones", "Watch", "Phone"],
    "montant":     [999, 699, 449, 99, 299, 699]
})

produits = pd.DataFrame({
    "produit":     ["Laptop", "Phone", "Tablet", "Watch"],
    "categorie":   ["Informatique", "Mobile", "Informatique", "Accessoire"],
    "marque":      ["Dell", "Apple", "Samsung", "Apple"]
})

# ─── INNER JOIN : LIGNES COMMUNES SEULEMENT ──────────────────
inner = pd.merge(
    commandes,
    clients,
    on="client_id",    # Colonne commune
    how="inner"        # INNER JOIN (défaut)
)
# 5 lignes (client_id 6 de commandes est EXCLUS car n'est pas dans clients)
print(f"Inner join : {len(inner)} lignes")

# ─── LEFT JOIN : TOUTES LES LIGNES DE GAUCHE ─────────────────
left = pd.merge(commandes, clients, on="client_id", how="left")
# 6 lignes (commande avec client_id=6 -> NaN pour les colonnes clients)
print(left[left["client_id"] == 6])   # nom et ville = NaN

# ─── RIGHT JOIN : TOUTES LES LIGNES DE DROITE ────────────────
right = pd.merge(commandes, clients, on="client_id", how="right")
# Clients sans commandes apparaissent avec NaN pour commandes

# ─── OUTER JOIN : TOUTES LES LIGNES DES DEUX ─────────────────
outer = pd.merge(commandes, clients, on="client_id", how="outer")
# Inclut tout : commandes orphelines ET clients sans commandes

# ─── COLONNES DE CLÉ AVEC NOMS DIFFÉRENTS ────────────────────
df_a = pd.DataFrame({"cust_id": [1, 2], "val": [10, 20]})
df_b = pd.DataFrame({"customer_id": [1, 3], "info": ["A", "C"]})

result = pd.merge(df_a, df_b,
                  left_on="cust_id",      # Clé dans df_a
                  right_on="customer_id",  # Clé dans df_b
                  how="left")

# ─── MERGE SUR L'INDEX ────────────────────────────────────────
df1 = pd.DataFrame({"A": [1, 2, 3]}, index=["a", "b", "c"])
df2 = pd.DataFrame({"B": [10, 20]},  index=["a", "c"])

result = pd.merge(df1, df2, left_index=True, right_index=True, how="left")
# Join sur l'index

# ─── MERGE MULTIPLE ───────────────────────────────────────────
# Fusionner 3 tables
complet = (commandes
           .merge(clients, on="client_id", how="left")
           .merge(produits, on="produit",  how="left"))

print(complet.columns.tolist())
# ['commande_id', 'client_id', 'produit', 'montant', 'nom', 'ville', 'categorie', 'marque']

# ─── SUFFIXES POUR LES COLONNES DUPLIQUÉES ────────────────────
df_a = pd.DataFrame({"id": [1, 2], "date": ["2024-01", "2024-02"], "val": [100, 200]})
df_b = pd.DataFrame({"id": [1, 2], "date": ["2024-01", "2024-01"], "bonus": [10, 20]})

result = pd.merge(df_a, df_b, on=["id", "date"])
# Si "date" diffère -> utiliser suffixes
result = pd.merge(df_a, df_b, on="id",
                  suffixes=("_gauche", "_droite"))
# Colonnes : id, date_gauche, val, date_droite, bonus

# ─── VÉRIFICATION APRÈS MERGE ─────────────────────────────────
def verifier_merge(df_left, df_right, df_result, cle):
    """Vérifie l'intégrité d'un merge."""
    n_avant_gauche = len(df_left)
    n_avant_droite = len(df_right)
    n_apres = len(df_result)

    print(f"Lignes gauche : {n_avant_gauche}")
    print(f"Lignes droite : {n_avant_droite}")
    print(f"Lignes résultat : {n_apres}")
    print(f"Clés manquantes : {df_result[cle].isna().sum()}")

verifier_merge(commandes, clients, inner, "client_id")
```

================================================================================
CHAPITRE 21 — PIVOT TABLES ET CROSSTAB
================================================================================

────────────────────────────────────────
21.1 PIVOT TABLE
────────────────────────────────────────

```python
import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    "date":      pd.date_range("2024-01-01", periods=200, freq="D").repeat(1)[:200],
    "ville":     np.random.choice(["Paris", "Lyon", "Marseille"], 200),
    "produit":   np.random.choice(["Laptop", "Phone", "Tablet"], 200),
    "vendeur":   np.random.choice(["Alice", "Bob", "Carol"], 200),
    "ventes":    np.random.randint(1, 20, 200),
    "CA":        np.random.uniform(100, 2000, 200).round(2)
})
df["mois"] = df["date"].dt.month_name()

# ─── PIVOT TABLE BASIQUE ─────────────────────────────────────
pivot = pd.pivot_table(
    df,
    values="CA",           # Valeur à agréger
    index="ville",         # Lignes
    columns="produit",     # Colonnes
    aggfunc="sum",         # Fonction d'agrégation
    fill_value=0           # Remplacer NaN par 0
)
print(pivot)
# produit   Laptop    Phone   Tablet
# ville
# Lyon        ...      ...      ...
# Marseille   ...      ...      ...
# Paris       ...      ...      ...

# Avec marges (totaux)
pivot_avec_totaux = pd.pivot_table(
    df,
    values="CA",
    index="ville",
    columns="produit",
    aggfunc="sum",
    fill_value=0,
    margins=True,           # Ajouter ligne/colonne Total
    margins_name="TOTAL"
)

# ─── PIVOT TABLE MULTI-VALEURS ────────────────────────────────
pivot_multi = pd.pivot_table(
    df,
    values=["CA", "ventes"],     # Deux valeurs
    index=["ville", "vendeur"],   # Deux niveaux de lignes
    columns="produit",
    aggfunc={
        "CA":     "sum",         # Somme du CA
        "ventes": "mean"         # Moyenne des ventes
    },
    fill_value=0
)
print(pivot_multi)

# ─── MELT : INVERSE DU PIVOT ─────────────────────────────────
# Transformer de "wide" en "long" format
df_wide = pd.DataFrame({
    "ville":  ["Paris", "Lyon"],
    "Jan":    [1000, 500],
    "Feb":    [1200, 600],
    "Mar":    [900, 700]
})

df_long = pd.melt(
    df_wide,
    id_vars=["ville"],           # Colonnes à garder
    value_vars=["Jan", "Feb", "Mar"],  # Colonnes à déplier
    var_name="mois",             # Nom de la nouvelle colonne de mois
    value_name="ventes"          # Nom de la colonne de valeurs
)
print(df_long)
#    ville mois  ventes
# 0  Paris  Jan    1000
# 1   Lyon  Jan     500
# 2  Paris  Feb    1200
# 3   Lyon  Feb     600
# 4  Paris  Mar     900
# 5   Lyon  Mar     700

# ─── PIVOT (SANS AGRÉGATION) ─────────────────────────────────
df_long_unique = pd.DataFrame({
    "date":     ["2024-01", "2024-01", "2024-02", "2024-02"],
    "ville":    ["Paris", "Lyon", "Paris", "Lyon"],
    "ventes":   [100, 50, 120, 60]
})

df_pivot = df_long_unique.pivot(
    index="date",    # Lignes
    columns="ville", # Colonnes
    values="ventes"  # Valeurs
)
print(df_pivot)
# ville    Lyon  Paris
# date
# 2024-01    50    100
# 2024-02    60    120

# ─── CROSSTAB : TABLEAUX DE CONTINGENCE ──────────────────────
# Fréquences croisées entre deux variables catégorielles
ct = pd.crosstab(df["ville"], df["produit"])
print(ct)
# produit  Laptop  Phone  Tablet
# ville
# Lyon         20     22      18
# Marseille    25     18      22
# Paris        30     28      17

# Avec normalisation (proportions)
ct_norm = pd.crosstab(df["ville"], df["produit"], normalize="index")
# normalize="index"  : proportions par ligne (somme = 1 par ville)
# normalize="columns": proportions par colonne
# normalize="all"    : proportions sur le total

# Avec valeurs et agrégation
ct_ca = pd.crosstab(
    df["ville"], df["produit"],
    values=df["CA"],
    aggfunc="sum"
)

# Avec marges
ct_marges = pd.crosstab(df["ville"], df["produit"], margins=True)
```

────────────────────────────────────────
21.2 STACK ET UNSTACK
────────────────────────────────────────

```python
# ─── STACK : COLONNES -> INDEX ────────────────────────────────
# Transforme les colonnes en niveaux d'index

df_wide = pd.DataFrame(
    {"A": [1, 2], "B": [3, 4]},
    index=["x", "y"]
)

df_stacked = df_wide.stack()
# x  A    1
#    B    3
# y  A    2
#    B    4

# ─── UNSTACK : INDEX -> COLONNES ──────────────────────────────
df_unstacked = df_stacked.unstack()
# Revient au format wide original

# Utile pour transformer un groupby multi-niveau
result = df.groupby(["ville", "produit"])["CA"].sum()
# Index hiérarchique (ville, produit) -> Series

pivot = result.unstack("produit")
# pivot devient un DataFrame avec produits en colonnes
```

────────────────────────────────────────
21.3 EXERCICES PRATIQUES — PARTIE 4
────────────────────────────────────────

EXERCICES FACILES :
  1. Chargez un DataFrame de 100 employés (générez les données)
     et affichez : head, describe, dtypes, isnull().sum()

  2. Filtrez les employés du département "IT" gagnant plus de 50000€

  3. Calculez le salaire moyen par département avec groupby

EXERCICES INTERMÉDIAIRES :
  4. Créez un pivot table montrant les ventes totales par mois (lignes)
     et par produit (colonnes) pour un dataset de ventes

  5. Fusionnez deux DataFrames (clients et commandes) avec un LEFT JOIN
     et identifiez les clients sans commandes

  6. Utilisez transform pour ajouter une colonne "% du CA du département"
     à chaque ligne

EXERCICES AVANCÉS :
  7. Créez une analyse complète multi-niveaux : groupby par ville ET produit,
     avec nlargest dans chaque groupe, puis merge avec une table de prix

  8. Implémentez un "dashboard" Pandas qui génère automatiquement
     des KPIs (top 5 clients, CA par mois, taux de croissance M/M)

  9. Partez d'un CSV réel (ex: Titanic sur Kaggle), faites le nettoyage,
     l'exploration, le groupby, le pivot table et le merge avec un dataset
     de ports

────────────────────────────────────────
21.4 CORRIGÉS DÉTAILLÉS
────────────────────────────────────────

CORRIGÉ EXERCICE 6 (Transform % du CA) :
```python
import pandas as pd
import numpy as np

np.random.seed(42)
df = pd.DataFrame({
    "employe":   [f"E{i}" for i in range(20)],
    "dept":      np.random.choice(["IT", "Finance", "RH"], 20),
    "CA":        np.random.uniform(10000, 100000, 20).round(2)
})

# Étape 1 : Calculer le CA total par département (via transform)
df["CA_total_dept"] = df.groupby("dept")["CA"].transform("sum")

# Étape 2 : Calculer le pourcentage
df["pct_CA_dept"] = (df["CA"] / df["CA_total_dept"] * 100).round(2)

# Vérification : la somme des % par département doit être ~100%
print(df.groupby("dept")["pct_CA_dept"].sum())
# dept
# Finance    100.0
# IT         100.0
# RH         100.0

# Affichage final
print(df.sort_values(["dept", "pct_CA_dept"], ascending=[True, False]))
```

================================================================================
RÉSUMÉ DE LA PARTIE 4
================================================================================

Dans cette partie, nous avons maîtrisé Pandas de manière approfondie :

  [OK] Series et DataFrame : création, exploration, attributs
  [OK] Chargement de données depuis CSV, Excel, JSON
  [OK] Manipulation des colonnes : création, calcul, apply, map, str
  [OK] .loc / .iloc : accès précis par étiquette ou position
  [OK] Index avancé : DatetimeIndex, MultiIndex
  [OK] Filtrage avancé : masques booléens, isin, between, query
  [OK] GroupBy : split-apply-combine, transform, filter, apply
  [OK] Merge et Join : inner, left, right, outer, multi-clés
  [OK] Concat : empilement vertical et horizontal
  [OK] Pivot Table, Crosstab, Stack/Unstack, Melt

PROCHAINE ÉTAPE :
  Partie 5 — Nettoyage des données (données manquantes, doublons,
  outliers, normalisation)

================================================================================
FIN DE LA PARTIE 4
================================================================================

================================================================================
     GUIDE COMPLET DATA SCIENCE AVEC PYTHON — PARTIE 5
     NETTOYAGE DES DONNÉES (DATA CLEANING)
     Chapitres 22 à 25
================================================================================

"Les données propres sont la fondation de toute analyse fiable.
 80% du travail d'un data scientist est du nettoyage."
 — Dicton universel en data science

================================================================================
CHAPITRE 22 — DONNÉES MANQUANTES (MISSING DATA)
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE LES DONNÉES MANQUANTES ?

Dans tout dataset réel, certaines valeurs sont absentes. Cela se manifeste par :
- NaN (Not a Number) — valeur spéciale de Python/NumPy/Pandas
- None — l'objet Python null
- "" — chaîne vide (souvent mal interprétée comme manquante)
- 0 — zéro utilisé à tort pour représenter "pas de valeur"
- -999, -1, 9999 — valeurs sentinelles utilisées dans certains systèmes

POURQUOI LES DONNÉES SONT-ELLES MANQUANTES ?

Raisons techniques :
  - Erreur de saisie ou oubli
  - Capteur défaillant
  - Champ optionnel non rempli
  - Fusion de datasets avec colonnes différentes
  - Suppression volontaire (données sensibles)

Raisons structurelles :
  - Question ne s'applique pas (ex: "nb d'enfants" pour personne célibataire)
  - Donnée pas encore disponible (mesure future)
  - Donnée perdue lors d'une migration

DANS QUELS CAS ON L'UTILISE ?

TOUJOURS. Il n'existe pas de dataset réel sans données manquantes.
Avant toute analyse, on doit :
1. Détecter les valeurs manquantes
2. Comprendre POURQUOI elles manquent (le mécanisme)
3. Choisir la stratégie de traitement appropriée

────────────────────────────────────────────────────────────────────────────────
2. EXPLICATION THÉORIQUE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

LES 3 MÉCANISMES DE DONNÉES MANQUANTES (Rubin, 1976)

Cette classification statistique est FONDAMENTALE pour choisir la bonne stratégie :

──────────────────────────────────────────────────────
MCAR — Missing Completely At Random (Manquant complètement aléatoirement)
──────────────────────────────────────────────────────

Définition : La probabilité qu'une valeur soit manquante ne dépend ni de la
             valeur manquante elle-même, ni d'autres variables.

Exemple : Un enquêteur renverse son café sur une partie des formulaires.
          Les pages détruites sont choisies au hasard pur.

Test statistique : Test de Little (H0: les données sont MCAR)
  from scipy import stats
  # Si p-value > 0.05, on ne peut pas rejeter MCAR

Conséquence : On peut supprimer les lignes sans biais. Mais c'est rare en pratique.

──────────────────────────────────────────────────────
MAR — Missing At Random (Manquant de façon aléatoire conditionnellement)
──────────────────────────────────────────────────────

Définition : La probabilité qu'une valeur manque dépend d'autres variables
             OBSERVÉES, mais pas de la valeur manquante elle-même.

Exemple : Les hommes répondent moins à la question "poids" que les femmes.
          La missingness dépend du sexe (observé), pas du poids réel.

Conséquence : L'imputation est valide si on conditionne sur les bonnes variables.

──────────────────────────────────────────────────────
MNAR — Missing Not At Random (Manquant non aléatoirement)
──────────────────────────────────────────────────────

Définition : La probabilité qu'une valeur manque dépend de la valeur
             manquante elle-même.

Exemple : Les personnes avec des revenus très élevés ne déclarent pas leur revenu.
          Les personnes déprimées ne répondent pas au questionnaire de santé mentale.

Conséquence : C'est le cas le plus difficile. L'imputation peut introduire un biais.
              Il faut modéliser explicitement le mécanisme de manquance.

──────────────────────────────────────────────────────
IMPACT SUR L'ANALYSE
──────────────────────────────────────────────────────

Si vous ignorez les données manquantes :
  - Les statistiques sont biaisées (moyenne trop haute/basse)
  - Les modèles ML refusent de tourner (sklearn refuse les NaN)
  - Les visualisations sont incomplètes ou trompeuses
  - Les correlations sont fausses

────────────────────────────────────────────────────────────────────────────────
3. COMMENT ÇA FONCTIONNE ?
────────────────────────────────────────────────────────────────────────────────

Pipeline de traitement des données manquantes :

Dataset brut avec NaN
       v
Détection & Audit (isnull, info, heatmap)
       v
Analyse du mécanisme (MCAR ? MAR ? MNAR ?)
       v
Choix de stratégie
  ├── Suppression (dropna) -> si MCAR et peu de NaN
  ├── Imputation simple (fillna) -> mean/median/mode
  ├── Imputation avancée -> KNN, régression, itérative
  └── Indicateur de manquance -> créer colonne binaire
       v
Validation (les distributions sont-elles préservées ?)
       v
Dataset propre

────────────────────────────────────────────────────────────────────────────────
4. POURQUOI UTILISER CES TECHNIQUES ?
────────────────────────────────────────────────────────────────────────────────

AVANTAGES de chaque approche :

Suppression (dropna) :
  [OK] Simple, rapide
  [OK] Pas de biais si MCAR
  [X] Perte de données (coûteux si dataset petit)
  [X] Biais si MAR ou MNAR

Imputation par la moyenne/médiane :
  [OK] Rapide, facile à expliquer
  [OK] Ne crée pas de lignes supplémentaires
  [X] Réduit la variance artificellement
  [X] Ignore les relations entre variables

Imputation KNN :
  [OK] Utilise les patterns inter-variables
  [OK] Plus précis que mean/median
  [X] Lent sur grands datasets
  [X] Sensible aux outliers

Imputation itérative (MICE) :
  [OK] Modélise les relations complexes
  [OK] Plusieurs imputations possibles (incertitude)
  [X] Très lent
  [X] Risque de surapprentissage

────────────────────────────────────────────────────────────────────────────────
5. QUAND UTILISER ?
────────────────────────────────────────────────────────────────────────────────

BUSINESS (RH, marketing) :
  - Questionnaires clients incomplets -> imputation par mode (catégories)
  - CRM avec champs optionnels -> indicateur de manquance + imputation

FINANCE :
  - Prix d'actions manquants sur jours fériés -> forward fill (dernière valeur connue)
  - Données de crédit incomplètes -> KNN imputation

SANTÉ :
  - Résultats biologiques non mesurés -> imputation multiple (MICE)
  - Patients perdus de vue -> analyse de sensibilité MNAR

IA / ML :
  - Avant sklearn : dropna ou SimpleImputer obligatoire
  - Avant deep learning : interpolation ou masque de manquance

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 22 — GESTION COMPLÈTE DES DONNÉES MANQUANTES
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer

# ─────────────────────────────────────────────────────────
# ÉTAPE 1 : Création d'un dataset réaliste avec NaN
# ─────────────────────────────────────────────────────────
np.random.seed(42)
n = 200

# Dataset de patients médicaux
data = {
    'age': np.random.randint(18, 80, n).astype(float),
    'poids': np.random.normal(70, 15, n),
    'taille': np.random.normal(170, 10, n),
    'tension': np.random.normal(120, 20, n),
    'cholesterol': np.random.normal(200, 40, n),
    'glucose': np.random.normal(100, 25, n),
    'sexe': np.random.choice(['M', 'F'], n),
    'fumeur': np.random.choice(['Oui', 'Non', np.nan], n, p=[0.25, 0.65, 0.10]),
    'diagnostic': np.random.choice(['Sain', 'Diabete', 'Hypertension'], n)
}

df = pd.DataFrame(data)

# Introduire des NaN de manière réaliste
# MCAR : 5% des âges manquants au hasard
mask_age = np.random.random(n) < 0.05
df.loc[mask_age, 'age'] = np.nan

# MAR : les hommes cachent leur poids plus souvent
mask_poids = (df['sexe'] == 'M') & (np.random.random(n) < 0.15)
df.loc[mask_poids, 'poids'] = np.nan

# MNAR : les patients hypertendus ne mesurent pas leur tension
mask_tension = (df['diagnostic'] == 'Hypertension') & (np.random.random(n) < 0.30)
df.loc[mask_tension, 'tension'] = np.nan

# NaN aléatoires sur cholesterol et glucose (MCAR)
for col in ['cholesterol', 'glucose']:
    mask = np.random.random(n) < 0.08
    df.loc[mask, col] = np.nan

print("Dataset créé :")
print(df.head(8))
print(f"\nShape : {df.shape}")

# ─────────────────────────────────────────────────────────
# ÉTAPE 2 : AUDIT COMPLET DES DONNÉES MANQUANTES
# ─────────────────────────────────────────────────────────

def audit_missing(dataframe, nom="DataFrame"):
    """
    Fonction complète d'audit des données manquantes.
    Retourne un rapport détaillé.
    """
    print(f"\n{'='*60}")
    print(f"AUDIT DONNÉES MANQUANTES — {nom}")
    print(f"{'='*60}")
    
    # Nombre total de cellules
    total_cells = dataframe.shape[0] * dataframe.shape[1]
    
    # Par colonne
    missing_per_col = dataframe.isnull().sum()
    pct_per_col = (missing_per_col / len(dataframe) * 100).round(2)
    
    # Résumé
    rapport = pd.DataFrame({
        'Manquants': missing_per_col,
        'Pourcentage': pct_per_col,
        'Type': dataframe.dtypes
    }).sort_values('Manquants', ascending=False)
    
    print(rapport)
    print(f"\nTotal cellules : {total_cells}")
    print(f"Total manquants : {dataframe.isnull().sum().sum()}")
    print(f"Taux global : {dataframe.isnull().sum().sum() / total_cells * 100:.2f}%")
    
    # Lignes avec au moins 1 NaN
    lignes_avec_nan = dataframe.isnull().any(axis=1).sum()
    print(f"Lignes avec ≥1 NaN : {lignes_avec_nan} ({lignes_avec_nan/len(dataframe)*100:.1f}%)")
    
    return rapport

rapport = audit_missing(df, "Dataset Patients")

# ─────────────────────────────────────────────────────────
# ÉTAPE 3 : VISUALISATION DES DONNÉES MANQUANTES
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Heatmap des NaN
# True = manquant (blanc), False = présent (sombre)
sns.heatmap(
    df.isnull(),          # Matrice booléenne True/False
    yticklabels=False,    # Cacher les index (trop nombreux)
    cbar=False,           # Pas de colorbar
    cmap='viridis',       # Palette de couleurs
    ax=axes[0]
)
axes[0].set_title("Heatmap des valeurs manquantes\n(jaune = manquant)")
axes[0].set_xlabel("Colonnes")

# Barplot du pourcentage de NaN
missing_pct = df.isnull().mean() * 100
missing_pct = missing_pct[missing_pct > 0]  # Garder seulement colonnes avec NaN
missing_pct.sort_values(ascending=False).plot(
    kind='bar',
    ax=axes[1],
    color='coral',
    edgecolor='black'
)
axes[1].set_title("Pourcentage de données manquantes par colonne")
axes[1].set_ylabel("% manquant")
axes[1].set_xlabel("Colonnes")
for bar in axes[1].patches:
    axes[1].text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + 0.3,
        f'{bar.get_height():.1f}%',
        ha='center', va='bottom', fontsize=9
    )

plt.tight_layout()
plt.savefig("missing_data_audit.png", dpi=150, bbox_inches='tight')
plt.show()
print("Graphique sauvegardé.")

# ─────────────────────────────────────────────────────────
# ÉTAPE 4 : DÉTECTION AVANCÉE — CO-OCCURRENCE DES NaN
# ─────────────────────────────────────────────────────────

# Corrélation entre les patterns de manquance
# Si deux colonnes ont NaN ensemble souvent -> lien structurel
missing_corr = df.isnull().corr()
print("\nCorrélation des patterns de manquance :")
print(missing_corr.round(3))
# Une corrélation forte suggère MAR (un NaN en cause un autre)

# ─────────────────────────────────────────────────────────
# ÉTAPE 5 : STRATÉGIE 1 — SUPPRESSION (DROPNA)
# ─────────────────────────────────────────────────────────

# dropna() supprime les lignes/colonnes avec NaN

# Supprimer les lignes avec TOUS les NaN (rare)
df_drop_all = df.dropna(how='all')   # 'all' : tous les champs sont NaN
print(f"\nAprès dropna(how='all') : {df_drop_all.shape}")

# Supprimer les lignes avec AU MOINS UN NaN
df_drop_any = df.dropna(how='any')   # 'any' : au moins un NaN
print(f"Après dropna(how='any') : {df_drop_any.shape}")
# [ATTENTION] On perd beaucoup de lignes !

# Supprimer les lignes avec NaN dans des colonnes SPÉCIFIQUES
df_drop_specific = df.dropna(
    subset=['age', 'poids']   # On exige que age ET poids soient non-NaN
)
print(f"Après dropna(subset=['age','poids']) : {df_drop_specific.shape}")

# Supprimer les colonnes avec plus de 50% de NaN
seuil = 0.5
df_drop_cols = df.dropna(
    axis=1,                          # axis=1 = colonnes (vs 0 = lignes)
    thresh=int(n * (1 - seuil))      # Garder si au moins 50% non-NaN
)
print(f"Après suppression colonnes >50% NaN : {df_drop_cols.shape}")

# thresh = nombre MINIMUM de valeurs non-NaN requises
# thresh=int(n*0.5) = au moins 50% de valeurs présentes

# ─────────────────────────────────────────────────────────
# ÉTAPE 6 : STRATÉGIE 2 — IMPUTATION SIMPLE (FILLNA)
# ─────────────────────────────────────────────────────────

df_imputed = df.copy()   # Toujours travailler sur une copie !

# FILLNA avec une valeur fixe
df_imputed['glucose'].fillna(
    df_imputed['glucose'].median(),   # Remplacer NaN par la médiane
    inplace=True
)
# Pourquoi médiane et pas moyenne ?
# La médiane est robuste aux outliers. Si glucose a des valeurs extrêmes,
# la médiane reste stable.

# FILLNA par la moyenne pour les colonnes numériques
colonnes_num = ['age', 'cholesterol']
for col in colonnes_num:
    moyenne = df_imputed[col].mean()
    df_imputed[col].fillna(moyenne, inplace=True)
    print(f"  {col} : {df_imputed[col].isnull().sum()} NaN restants")

# FILLNA par le mode pour les colonnes catégorielles
mode_fumeur = df_imputed['fumeur'].mode()[0]  # mode() retourne une Series
df_imputed['fumeur'].fillna(mode_fumeur, inplace=True)
print(f"\nMode fumeur utilisé : {mode_fumeur}")

# FILL par propagation (time series)
# Forward fill : remplacer NaN par la DERNIÈRE valeur connue
df_ffill = df.copy()
df_ffill['tension'] = df_ffill['tension'].fillna(method='ffill')
# Utile pour des séries temporelles (ex: prix d'actions le week-end)

# Backward fill : remplacer NaN par la PROCHAINE valeur connue
df_bfill = df.copy()
df_bfill['tension'] = df_bfill['tension'].fillna(method='bfill')

# ─────────────────────────────────────────────────────────
# ÉTAPE 7 : STRATÉGIE 2bis — SKLEARN SimpleImputer
# ─────────────────────────────────────────────────────────

# SimpleImputer est compatible avec les pipelines sklearn
from sklearn.impute import SimpleImputer

# Sélectionner les colonnes numériques
cols_num = df.select_dtypes(include=[np.number]).columns.tolist()

# Créer l'imputeur
imputer_mean = SimpleImputer(
    strategy='mean'      # 'mean', 'median', 'most_frequent', 'constant'
)

# Fit sur les données (calcule les moyennes)
# Transform applique l'imputation
X_num = df[cols_num].copy()
X_imputed = imputer_mean.fit_transform(X_num)

# Convertir en DataFrame
df_sklearn_imputed = pd.DataFrame(
    X_imputed,
    columns=cols_num,
    index=df.index
)

print("\nAprès SimpleImputer(strategy='mean') :")
print(df_sklearn_imputed.isnull().sum())  # Doit être 0 partout

# ─────────────────────────────────────────────────────────
# ÉTAPE 8 : STRATÉGIE 3 — IMPUTATION KNN
# ─────────────────────────────────────────────────────────

# KNN Imputer : pour chaque NaN, trouve les K voisins les plus proches
# et utilise leur moyenne pondérée pour imputer.

from sklearn.impute import KNNImputer

knn_imputer = KNNImputer(
    n_neighbors=5,        # Utiliser 5 voisins
    weights='uniform'     # 'uniform' = poids égaux, 'distance' = pondéré
)

X_num = df[cols_num].copy()

# Fit + transform en une étape
X_knn = knn_imputer.fit_transform(X_num)

df_knn = pd.DataFrame(X_knn, columns=cols_num, index=df.index)

print("\nAprès KNNImputer(n_neighbors=5) :")
print(df_knn.isnull().sum())
print("\nComparaison tension (mean vs KNN) :")
print(f"  Mean imputed : {df_sklearn_imputed['tension'].mean():.2f}")
print(f"  KNN imputed  : {df_knn['tension'].mean():.2f}")
print(f"  Original mean: {df['tension'].mean():.2f} (avec NaN ignorés)")

# ─────────────────────────────────────────────────────────
# ÉTAPE 9 : STRATÉGIE 4 — IMPUTATION ITÉRATIVE (MICE)
# ─────────────────────────────────────────────────────────

# MICE (Multiple Imputation by Chained Equations)
# Modélise chaque colonne avec NaN comme variable dépendante
# et utilise les autres colonnes comme prédicteurs.
# Répète plusieurs fois jusqu'à convergence.

from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
from sklearn.linear_model import BayesianRidge

mice_imputer = IterativeImputer(
    estimator=BayesianRidge(),  # Modèle utilisé pour prédire
    n_nearest_features=None,    # Utiliser toutes les features
    max_iter=10,                # Nombre d'itérations
    random_state=42,
    verbose=0
)

X_mice = mice_imputer.fit_transform(X_num)
df_mice = pd.DataFrame(X_mice, columns=cols_num, index=df.index)

print("\nAprès IterativeImputer (MICE) :")
print(df_mice.isnull().sum())

# ─────────────────────────────────────────────────────────
# ÉTAPE 10 : STRATÉGIE 5 — INDICATEUR DE MANQUANCE
# ─────────────────────────────────────────────────────────

# Pour les cas MNAR, créer une colonne indicateur
# La manquance elle-même est une information !

df_indicator = df.copy()

for col in ['tension', 'poids']:
    col_indicateur = f"{col}_manquant"
    df_indicator[col_indicateur] = df_indicator[col].isnull().astype(int)
    # 1 = manquant, 0 = présent
    print(f"\n{col_indicateur} :")
    print(df_indicator[col_indicateur].value_counts())

# Cette technique est utilisée en ML :
# Le modèle peut apprendre que "tension_manquant = 1 -> Hypertension probable"
# ce qui serait une information précieuse !

# ─────────────────────────────────────────────────────────
# ÉTAPE 11 : INTERPOLATION (pour séries temporelles)
# ─────────────────────────────────────────────────────────

# Créer une série temporelle
dates = pd.date_range('2024-01-01', periods=30, freq='D')
valeurs = [100, 102, np.nan, 98, 105, np.nan, np.nan, 110, 108, 115,
           np.nan, 120, 118, 125, np.nan, 130, 128, 135, np.nan, 140,
           138, 145, np.nan, np.nan, 148, 150, 152, np.nan, 158, 160]

ts = pd.Series(valeurs, index=dates)

# Interpolation linéaire (défaut)
ts_linear = ts.interpolate(method='linear')

# Interpolation cubique (courbe plus lisse)
ts_cubic = ts.interpolate(method='cubic')

# Interpolation temporelle (pondérée par les intervalles de temps)
ts_time = ts.interpolate(method='time')

# Visualisation
plt.figure(figsize=(12, 5))
plt.plot(ts.index, ts.values, 'o-', label='Original (NaN = gaps)', alpha=0.5)
plt.plot(ts_linear.index, ts_linear.values, '--', label='Linéaire', color='blue')
plt.plot(ts_cubic.index, ts_cubic.values, ':', label='Cubique', color='green')
plt.title("Interpolation pour données manquantes en série temporelle")
plt.legend()
plt.xlabel("Date")
plt.ylabel("Valeur")
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("interpolation_ts.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# ÉTAPE 12 : VALIDATION — DISTRIBUTION AVANT/APRÈS
# ─────────────────────────────────────────────────────────

# Toujours vérifier que l'imputation ne biaise pas la distribution !

fig, axes = plt.subplots(2, 3, figsize=(15, 8))

for i, col in enumerate(['age', 'poids', 'tension', 'cholesterol', 'glucose']):
    ax = axes[i // 3][i % 3]
    
    # Valeurs originales (sans NaN)
    originales = df[col].dropna()
    
    # Valeurs imputées par KNN
    imputees = df_knn[col]
    
    ax.hist(originales, bins=20, alpha=0.6, label=f'Original (n={len(originales)})', 
            color='blue', density=True)
    ax.hist(imputees, bins=20, alpha=0.6, label=f'KNN imputé (n={len(imputees)})', 
            color='red', density=True)
    ax.set_title(f"{col}")
    ax.legend(fontsize=7)
    ax.set_xlabel("Valeur")
    ax.set_ylabel("Densité")

axes[1][2].axis('off')  # Cacher le 6ème subplot vide
fig.suptitle("Validation : Distribution avant/après imputation KNN", fontsize=13)
plt.tight_layout()
plt.savefig("imputation_validation.png", dpi=150, bbox_inches='tight')
plt.show()

# Vérification quantitative : moyennes doivent être proches
print("\nValidation quantitative (moyennes) :")
for col in cols_num:
    orig_mean = df[col].mean()
    knn_mean = df_knn[col].mean()
    diff = abs(orig_mean - knn_mean) / orig_mean * 100
    print(f"  {col:15s} : Original={orig_mean:.2f}, KNN={knn_mean:.2f}, Diff={diff:.2f}%")
```

────────────────────────────────────────────────────────────────────────────────
7. BONNES PRATIQUES PROFESSIONNELLES
────────────────────────────────────────────────────────────────────────────────

RÈGLE 1 : Toujours auditer AVANT de traiter
  # Ne jamais aveuglément dropna() sans comprendre
  print(df.isnull().sum())
  print(df.isnull().mean() * 100)

RÈGLE 2 : Travailler sur des copies
  df_clean = df.copy()   # JAMAIS modifier l'original
  # Permet de comparer avant/après

RÈGLE 3 : Documenter les choix
  # Dans un notebook :
  # "On impute 'poids' par la médiane car : distribution asymétrique (skew=1.2)"
  # "On supprime 'colonne_x' car : 85% de NaN, pas récupérable"

RÈGLE 4 : Valider la distribution après imputation
  # Si la distribution change radicalement -> mauvaise stratégie

RÈGLE 5 : Créer des indicateurs pour les MNAR
  df['poids_missing'] = df['poids'].isnull().astype(int)

RÈGLE 6 : Utiliser des pipelines sklearn pour la reproductibilité
  from sklearn.pipeline import Pipeline
  pipe = Pipeline([
      ('imputer', KNNImputer(n_neighbors=5)),
      ('scaler', StandardScaler()),
      ('model', LogisticRegression())
  ])
  # L'imputation est intégrée dans le pipeline -> pas de fuite de données

RÈGLE 7 : Seuils recommandés
  - < 5% NaN : n'importe quelle stratégie fonctionne
  - 5-20% NaN : imputation recommandée
  - 20-50% NaN : imputation avancée + indicateur
  - > 50% NaN : envisager de supprimer la colonne

────────────────────────────────────────────────────────────────────────────────
8. ERREURS FRÉQUENTES
────────────────────────────────────────────────────────────────────────────────

ERREUR 1 : Data leakage lors de l'imputation
  # MAUVAIS : fit sur l'ensemble du dataset (y compris test)
  imputer.fit(df_entire)
  
  # BON : fit seulement sur train, transform sur test
  imputer.fit(X_train)
  X_train_imp = imputer.transform(X_train)
  X_test_imp  = imputer.transform(X_test)   # Utilise les stats du train

ERREUR 2 : Imputer avant le split train/test -> biais car infos du test influencent train

ERREUR 3 : Utiliser la moyenne sur des données asymétriques
  # Si salaires : [30k, 35k, 40k, 200k] -> mean=76k, median=37k
  # La médiane est plus représentative !

ERREUR 4 : Oublier les variables catégorielles
  # fillna('Unknown') plutôt que fillna(0) pour les chaînes de caractères

ERREUR 5 : Ignorer les NaN "déguisés"
  # Dans Excel : cellule avec espace ' ' -> pas détecté comme NaN
  df['col'] = df['col'].replace(' ', np.nan)
  df['col'] = df['col'].replace('', np.nan)
  df['col'] = df['col'].replace('N/A', np.nan)
  df['col'] = df['col'].replace('null', np.nan)
  df['col'] = df['col'].replace(-999, np.nan)

ERREUR 6 : dropna() sans réinitialiser l'index
  df_clean = df.dropna()
  # L'index peut avoir des trous : 0, 1, 5, 8, 12...
  df_clean = df.dropna().reset_index(drop=True)
  # drop=True : ne pas garder l'ancien index comme colonne

────────────────────────────────────────────────────────────────────────────────
9. EXERCICES PRATIQUES
────────────────────────────────────────────────────────────────────────────────

── NIVEAU FACILE ──

Exercice 22.1 — Audit basique
  Créez un DataFrame de 100 étudiants avec des colonnes :
  'nom', 'age', 'note_math', 'note_français', 'ville'
  Introduisez 10% de NaN au hasard dans chaque colonne numérique.
  Affichez : nombre de NaN par colonne, pourcentage, total.

Exercice 22.2 — Imputation simple
  Avec le DataFrame précédent :
  - Imputez note_math avec la médiane
  - Imputez note_français avec la moyenne
  - Vérifiez qu'il ne reste plus de NaN

Exercice 22.3 — Suppression conditionnelle
  Chargez le dataset Titanic (disponible dans seaborn.load_dataset('titanic'))
  Supprimez les lignes où 'age' EST NaN.
  Supprimez la colonne 'deck' (trop de NaN).
  Comparez la forme avant/après.

── NIVEAU INTERMÉDIAIRE ──

Exercice 22.4 — Imputation par groupe
  Créez un dataset de ventes avec colonnes :
  'region', 'produit', 'ventes', 'prix_unitaire'
  Imputez les ventes manquantes par la médiane de leur region
  (et non la médiane globale).
  Indice : df.groupby('region')['ventes'].transform(lambda x: x.fillna(x.median()))

Exercice 22.5 — Validation visuelle
  Après imputation KNN sur un dataset numérique :
  - Tracez les distributions avant/après pour chaque colonne
  - Calculez la différence de moyenne et d'écart-type

Exercice 22.6 — Indicateur de manquance
  Sur le dataset Titanic :
  - Créez une colonne 'age_missing' (1 si age NaN, 0 sinon)
  - Calculez le taux de survie selon age_missing
  - Est-ce que les passagers avec age inconnu avaient un taux de survie différent ?

── NIVEAU AVANCÉ ──

Exercice 22.7 — Pipeline sklearn complet
  Créez un pipeline sklearn qui :
  1. Impute les numériques par KNN (n_neighbors=5)
  2. Impute les catégoriels par le mode
  3. Scale les numériques (StandardScaler)
  4. Encode les catégoriels (OneHotEncoder)
  5. Entraîne un RandomForest
  Testez sur un dataset de classification avec NaN.

Exercice 22.8 — Test MCAR avec chi-carré
  Vérifiez si les NaN dans 'age' sont indépendants du sexe.
  Créez une variable binaire 'age_missing'.
  Faites un test du chi-carré entre 'age_missing' et 'sexe'.
  H0 : les NaN sont indépendants du sexe (MCAR).
  Si p-value < 0.05 -> MAR (les hommes ont plus souvent age inconnu).

Exercice 22.9 — Comparaison des méthodes d'imputation
  Sur un dataset complet (sans NaN), masquez aléatoirement 20% des valeurs.
  Comparez 4 méthodes (mean, median, KNN, MICE) en RMSE :
  RMSE = sqrt(mean((valeur_réelle - valeur_imputée)²))
  Quelle méthode minimise l'erreur ?

────────────────────────────────────────────────────────────────────────────────
10. CORRIGÉS DÉTAILLÉS
────────────────────────────────────────────────────────────────────────────────

```python
# ── CORRIGÉ 22.1 ──
import pandas as pd
import numpy as np

np.random.seed(0)
n = 100
df_ex = pd.DataFrame({
    'nom': [f"Etudiant_{i}" for i in range(n)],
    'age': np.random.randint(17, 25, n).astype(float),
    'note_math': np.random.uniform(0, 20, n),
    'note_français': np.random.uniform(0, 20, n),
    'ville': np.random.choice(['Paris', 'Lyon', 'Marseille'], n)
})
# Introduire 10% de NaN
for col in ['age', 'note_math', 'note_français']:
    mask = np.random.random(n) < 0.10
    df_ex.loc[mask, col] = np.nan

print("NaN par colonne :")
print(df_ex.isnull().sum())
print("\nPourcentage :")
print((df_ex.isnull().mean() * 100).round(1))
print(f"\nTotal NaN : {df_ex.isnull().sum().sum()}")

# ── CORRIGÉ 22.2 ──
df_ex2 = df_ex.copy()
df_ex2['note_math'] = df_ex2['note_math'].fillna(df_ex2['note_math'].median())
df_ex2['note_français'] = df_ex2['note_français'].fillna(df_ex2['note_français'].mean())
print("\nNaN restants :")
print(df_ex2[['note_math', 'note_français']].isnull().sum())

# ── CORRIGÉ 22.3 ──
import seaborn as sns
titanic = sns.load_dataset('titanic')
print(f"Shape originale : {titanic.shape}")
titanic = titanic.dropna(subset=['age'])   # Supprimer lignes age NaN
print(f"Après dropna age : {titanic.shape}")
titanic = titanic.drop(columns=['deck'])   # Supprimer colonne deck
print(f"Après drop deck : {titanic.shape}")

# ── CORRIGÉ 22.4 ──
np.random.seed(42)
regions = ['Nord', 'Sud', 'Est', 'Ouest']
df_ventes = pd.DataFrame({
    'region': np.random.choice(regions, 200),
    'produit': np.random.choice(['A', 'B', 'C'], 200),
    'ventes': np.random.normal(1000, 200, 200),
    'prix_unitaire': np.random.normal(50, 10, 200)
})
# Introduire NaN
mask = np.random.random(200) < 0.15
df_ventes.loc[mask, 'ventes'] = np.nan

# Imputation par médiane de la région
df_ventes['ventes'] = df_ventes.groupby('region')['ventes'].transform(
    lambda x: x.fillna(x.median())
)
print(f"NaN restants : {df_ventes['ventes'].isnull().sum()}")

# ── CORRIGÉ 22.6 ──
titanic = sns.load_dataset('titanic')
titanic['age_missing'] = titanic['age'].isnull().astype(int)
print("\nTaux de survie selon age_missing :")
print(titanic.groupby('age_missing')['survived'].mean())
# Résultat attendu : les passagers sans âge connu survivent moins souvent
# -> le mécanisme est MAR (lié à la classe, au sexe, etc.)
```

================================================================================
CHAPITRE 23 — DOUBLONS
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QU'UN DOUBLON ?

Un doublon est une ligne qui apparaît plusieurs fois avec les mêmes valeurs
(exactement ou approximativement).

TYPES DE DOUBLONS :

Doublons EXACTS : Toutes les colonnes sont identiques
  Ligne 1 : John | 25 | Paris | 50000
  Ligne 2 : John | 25 | Paris | 50000   <- copie parfaite

Doublons PARTIELS : Seulement certaines colonnes sont identiques
  Ligne 1 : John | 25 | Paris | 50000
  Ligne 2 : John | 25 | Lyon  | 52000   <- même John mais adresse différente ?

Doublons FLOUS (fuzzy duplicates) :
  Ligne 1 : Jean Dupont
  Ligne 2 : Jean-Dupont   <- même personne ? Tiret différent
  Ligne 3 : JEAN DUPONT   <- majuscules

POURQUOI ÇA ARRIVE ?

- Fusion de plusieurs bases de données (même client dans CRM et ERP)
- Saisies multiples du même formulaire
- Scraping web (même article sur plusieurs pages)
- Migrations de systèmes
- Erreurs d'export/import

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 23 — GESTION COMPLÈTE DES DOUBLONS
# ============================================================

import pandas as pd
import numpy as np

# Dataset clients avec doublons intentionnels
data = {
    'id_client': [1, 2, 3, 2, 4, 5, 5, 6, 7, 8, 8, 9, 10, 3],
    'nom': ['Alice', 'Bob', 'Charlie', 'Bob', 'Diana', 'Eve', 'Eve',
            'Frank', 'Grace', 'Henry', 'Henry', 'Iris', 'Jack', 'CHARLIE'],
    'email': ['alice@mail.com', 'bob@mail.com', 'charlie@mail.com', 
               'bob@mail.com', 'diana@mail.com', 'eve@mail.com', 'eve@mail.com',
               'frank@mail.com', 'grace@mail.com', 'henry@mail.com', 
               'henry@mail.com', 'iris@mail.com', 'jack@mail.com', 'charlie@mail.com'],
    'age': [28, 35, 42, 35, 29, 31, 31, 45, 27, 38, 38, 33, 50, 42],
    'ventes': [1200, 3400, 850, 3400, 2100, 750, 750, 4200, 1800, 900, 950, 1500, 6000, 850]
}
df = pd.DataFrame(data)
print("Dataset original :")
print(df)
print(f"\nShape : {df.shape}")

# ─────────────────────────────────────────────────────────
# DÉTECTION DES DOUBLONS
# ─────────────────────────────────────────────────────────

# duplicated() retourne une Series booléenne
# True = la ligne est un doublon d'une ligne précédente
print("\nLignes dupliquées (toutes colonnes) :")
doublons_mask = df.duplicated()   # keep='first' par défaut
print(df[doublons_mask])

# keep='first'  : garder la première occurrence, marquer les suivantes
# keep='last'   : garder la dernière occurrence, marquer les premières
# keep=False    : marquer TOUTES les occurrences (y compris la première)

print(f"\nNombre de doublons exacts : {doublons_mask.sum()}")

# Doublons sur des colonnes spécifiques
print("\nDoublons sur id_client :")
doublons_id = df.duplicated(subset=['id_client'])
print(df[doublons_id | df.duplicated(subset=['id_client'], keep=False)])

# duplicated(subset=['id_client'], keep=False) -> toutes les occurrences d'un id en doublon

# ─────────────────────────────────────────────────────────
# SUPPRESSION DES DOUBLONS
# ─────────────────────────────────────────────────────────

# Supprimer les doublons exacts, garder la première occurrence
df_clean = df.drop_duplicates()
print(f"\nAprès drop_duplicates() : {df_clean.shape}")

# Garder la dernière occurrence (si la plus récente est la bonne)
df_clean_last = df.drop_duplicates(keep='last')
print(f"Après drop_duplicates(keep='last') : {df_clean_last.shape}")

# Supprimer basé sur une colonne clé (id_client)
df_clean_id = df.drop_duplicates(subset=['id_client'], keep='first')
print(f"Après drop_duplicates(subset=['id_client']) : {df_clean_id.shape}")

# ─────────────────────────────────────────────────────────
# DOUBLONS FLOUS — FUZZY MATCHING
# ─────────────────────────────────────────────────────────

# Cas 1 : Normalisation des chaînes
print("\nNoms avant normalisation :")
print(df['nom'].unique())

df_norm = df.copy()
df_norm['nom_normalise'] = (df_norm['nom']
    .str.lower()          # 'CHARLIE' -> 'charlie'
    .str.strip()          # Supprimer espaces avant/après
    .str.replace('-', ' ')  # 'Jean-Dupont' -> 'Jean Dupont'
    .str.replace(r'\s+', ' ', regex=True)  # Espaces multiples -> 1 espace
)

print("Noms après normalisation :")
print(df_norm['nom_normalise'].unique())

# Maintenant les doublons flous deviennent exacts
doublons_norm = df_norm.duplicated(subset=['nom_normalise'])
print(f"Doublons après normalisation : {doublons_norm.sum()}")

# Cas 2 : Fuzzy matching avec fuzzywuzzy
# pip install fuzzywuzzy python-Levenshtein
try:
    from fuzzywuzzy import fuzz, process
    
    noms = df['nom'].str.lower().unique().tolist()
    
    # Comparer chaque nom avec tous les autres
    print("\nFuzzy matching des noms :")
    for i, nom1 in enumerate(noms):
        for nom2 in noms[i+1:]:
            score = fuzz.ratio(nom1, nom2)
            if score > 70:   # Seuil de similarité
                print(f"  '{nom1}' <-> '{nom2}' : score={score}%")
    
    # process.extractBests : trouver les meilleurs matches pour un nom donné
    resultats = process.extractBests(
        'charlie',      # Nom de référence
        noms,           # Liste à chercher
        limit=3,
        score_cutoff=70  # Minimum de similarité
    )
    print(f"\nMeilleurs matches pour 'charlie' : {resultats}")
    
except ImportError:
    print("fuzzywuzzy non installé. Pour l'utiliser : pip install fuzzywuzzy")

# ─────────────────────────────────────────────────────────
# STRATÉGIE DE DÉDUPLICATION AVANCÉE
# ─────────────────────────────────────────────────────────

# Problème : pour id_client=8, les ventes sont différentes (900 vs 950)
# Quelle ligne garder ? -> Agréger !

print("\nDoublons avec valeurs différentes :")
print(df[df.duplicated(subset=['id_client'], keep=False)])

# Stratégie : groupby + agrégation
df_dedup = (df.groupby('id_client')
              .agg({
                  'nom': 'first',         # Premier nom rencontré
                  'email': 'first',       # Premier email
                  'age': 'first',         # Premier âge
                  'ventes': 'sum'         # Somme des ventes (si ce sont des transactions)
              })
              .reset_index())

print("\nAprès déduplication par agrégation :")
print(df_dedup)
```

────────────────────────────────────────────────────────────────────────────────
7-8. BONNES PRATIQUES ET ERREURS FRÉQUENTES
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES :
  1. Toujours inspecter les doublons avant de les supprimer
  2. Définir une CLÉ MÉTIER (id_client, email, etc.) et baser la déduplication dessus
  3. Décider d'une règle d'agrégation pour les champs conflictuels
  4. Logger le nombre de doublons supprimés

ERREURS FRÉQUENTES :
  - Supprimer des doublons sans reset_index() -> index avec trous
  - Confondre id_client unique et ligne unique
  - Oublier de normaliser les chaînes avant de comparer
  - Supprimer des "faux doublons" (même nom mais personnes différentes)

================================================================================
CHAPITRE 24 — OUTLIERS (VALEURS ABERRANTES)
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QU'UN OUTLIER ?

Un outlier (valeur aberrante) est une observation qui se démarque significativement
du reste des données.

TYPES D'OUTLIERS :

Outlier UNIVARIÉ : Valeur extrême dans une seule variable
  Salaires : [30k, 35k, 40k, 38k, 32k, 500k]
  -> 500k est un outlier

Outlier MULTIVARIÉ : Combinaison de valeurs inhabituelles
  Taille=180cm et Poids=30kg -> chacune normale seule, mais ensemble anormal

CAUSES :
  - Erreur de saisie (150 kg saisi au lieu de 15.0 kg)
  - Valeur réelle mais extrême (milliardaire dans dataset de salaires)
  - Erreur de mesure (capteur défaillant)
  - Événement rare mais réel (covid dans série temporelle)

TRAITEMENT :
  Il n'y a pas de règle universelle. La décision dépend du contexte :
  - Erreur de saisie -> corriger ou supprimer
  - Valeur réelle extrême -> souvent garder, mais utiliser des méthodes robustes
  - Événement rare -> documenter, analyser séparément

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 24 — DÉTECTION ET TRAITEMENT DES OUTLIERS
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

np.random.seed(42)
n = 200

# Dataset de salaires avec outliers
salaires = np.concatenate([
    np.random.normal(45000, 10000, n - 5),   # Données normales
    [200000, 250000, 300000, -5000, 150000]  # Outliers intentionnels
])
np.random.shuffle(salaires)

ages = np.random.randint(22, 65, n).astype(float)
ages[np.random.randint(0, n, 3)] = [5, 150, -10]  # Ages aberrants

df = pd.DataFrame({
    'age': ages,
    'salaire': salaires,
    'experience': np.random.randint(0, 40, n),
    'note_perf': np.random.normal(7, 1.5, n).clip(0, 10)
})

print("Statistiques initiales :")
print(df.describe())

# ─────────────────────────────────────────────────────────
# MÉTHODE 1 : RÈGLE DES 3 SIGMA (Z-SCORE)
# ─────────────────────────────────────────────────────────

# Z-score = (valeur - moyenne) / écart-type
# Valeur z=3 -> 3 écarts-types au-dessus de la moyenne
# Seuil classique : |z| > 3 -> outlier

def detecter_outliers_zscore(series, seuil=3):
    """
    Détecte les outliers par la méthode du Z-score.
    Retourne un masque booléen (True = outlier).
    """
    z_scores = np.abs(stats.zscore(series.dropna()))
    return pd.Series(z_scores > seuil, index=series.dropna().index)

# Application sur salaire
z_salaire = stats.zscore(df['salaire'])
outliers_z = np.abs(z_salaire) > 3

print(f"\nOutliers Z-score (|z|>3) dans salaire : {outliers_z.sum()}")
print(df[outliers_z][['age', 'salaire']])

# Visualisation du Z-score
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.hist(df['salaire'], bins=30, color='steelblue', edgecolor='black', alpha=0.7)
plt.axvline(df['salaire'].mean() + 3 * df['salaire'].std(), 
            color='red', linestyle='--', label='±3σ')
plt.axvline(df['salaire'].mean() - 3 * df['salaire'].std(), 
            color='red', linestyle='--')
plt.title("Distribution Salaires avec seuil Z-score")
plt.legend()

plt.subplot(1, 2, 2)
plt.scatter(range(len(df)), z_salaire, alpha=0.6)
plt.axhline(3, color='red', linestyle='--', label='Seuil +3σ')
plt.axhline(-3, color='red', linestyle='--', label='Seuil -3σ')
plt.title("Z-scores par observation")
plt.xlabel("Index")
plt.ylabel("Z-score")
plt.legend()
plt.tight_layout()
plt.savefig("outliers_zscore.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# MÉTHODE 2 : IQR (INTERQUARTILE RANGE) — BOXPLOT
# ─────────────────────────────────────────────────────────

# IQR = Q3 - Q1
# Seuil inférieur = Q1 - 1.5 * IQR
# Seuil supérieur = Q3 + 1.5 * IQR
# Tout en dehors de ces seuils = outlier selon Tukey

def detecter_outliers_iqr(series, facteur=1.5):
    """
    Détecte les outliers par la méthode IQR (Tukey, 1977).
    facteur=1.5 -> outlier classique
    facteur=3.0 -> outlier extrême
    """
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1
    
    borne_inf = Q1 - facteur * IQR
    borne_sup = Q3 + facteur * IQR
    
    outliers = (series < borne_inf) | (series > borne_sup)
    
    print(f"  Q1={Q1:.2f}, Q3={Q3:.2f}, IQR={IQR:.2f}")
    print(f"  Borne inf: {borne_inf:.2f}, Borne sup: {borne_sup:.2f}")
    print(f"  Outliers détectés: {outliers.sum()}")
    
    return outliers, borne_inf, borne_sup

print("\nIQR sur salaire :")
outliers_iqr_sal, b_inf_sal, b_sup_sal = detecter_outliers_iqr(df['salaire'])
print("\nIQR sur age :")
outliers_iqr_age, b_inf_age, b_sup_age = detecter_outliers_iqr(df['age'])

# Boxplot pour visualiser
fig, axes = plt.subplots(1, 3, figsize=(14, 5))

for i, col in enumerate(['salaire', 'age', 'experience']):
    axes[i].boxplot(df[col].dropna(), vert=True)
    axes[i].set_title(f"Boxplot — {col}")
    axes[i].set_ylabel(col)

plt.suptitle("Détection des outliers par boxplot (IQR)")
plt.tight_layout()
plt.savefig("outliers_boxplot.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# MÉTHODE 3 : ISOLATION FOREST (Machine Learning)
# ─────────────────────────────────────────────────────────

# IsolationForest isole les outliers en partitionnant aléatoirement l'espace.
# Les outliers sont isolés en moins de partitions que les points normaux.
# Avantage : fonctionne en multidimensionnel (outliers multivariés)

from sklearn.ensemble import IsolationForest

# Préparer les features numériques
X = df[['salaire', 'age', 'experience', 'note_perf']].copy()
X = X.fillna(X.median())   # Imputation minimale pour IsolationForest

iso_forest = IsolationForest(
    n_estimators=100,         # Nombre d'arbres
    contamination=0.05,       # Proportion attendue d'outliers (5%)
    random_state=42
)

# fit_predict retourne +1 (inlier) ou -1 (outlier)
predictions = iso_forest.fit_predict(X)

df['iso_outlier'] = predictions == -1   # True = outlier
print(f"\nIsolation Forest : {df['iso_outlier'].sum()} outliers détectés")
print(df[df['iso_outlier']][['age', 'salaire', 'experience']].head(10))

# Score d'anomalie (-1 à 0, proche de -1 = plus anormal)
df['anomaly_score'] = iso_forest.score_samples(X)

# ─────────────────────────────────────────────────────────
# COMPARAISON DES MÉTHODES
# ─────────────────────────────────────────────────────────

print("\nComparaison des méthodes :")
print(f"  Z-score (|z|>3)  : {outliers_z.sum()} outliers")
print(f"  IQR (facteur=1.5): {outliers_iqr_sal.sum()} outliers (salaire seul)")
print(f"  Isolation Forest : {df['iso_outlier'].sum()} outliers (multivarié)")

# ─────────────────────────────────────────────────────────
# TRAITEMENT DES OUTLIERS
# ─────────────────────────────────────────────────────────

# Option 1 : Suppression
df_sans_outliers = df[~outliers_z].copy()
print(f"\nAprès suppression outliers Z-score : {df_sans_outliers.shape}")

# Option 2 : Winsorization (capping/clipping)
# Remplacer les outliers par la valeur du seuil
df_winsor = df.copy()
df_winsor['salaire'] = df_winsor['salaire'].clip(
    lower=df_winsor['salaire'].quantile(0.01),  # 1er percentile
    upper=df_winsor['salaire'].quantile(0.99)   # 99ème percentile
)
print(f"\nAprès winsorization [1%, 99%] :")
print(f"  Avant: min={df['salaire'].min():.0f}, max={df['salaire'].max():.0f}")
print(f"  Après: min={df_winsor['salaire'].min():.0f}, max={df_winsor['salaire'].max():.0f}")

# Option 3 : Transformation (log pour réduire l'impact des extrêmes)
df['salaire_log'] = np.log1p(np.abs(df['salaire']))
# log1p(x) = log(1 + x) -> évite log(0)

# Option 4 : Remplacement par NaN puis imputation
df_replace = df.copy()
df_replace.loc[outliers_iqr_sal, 'salaire'] = np.nan
df_replace['salaire'] = df_replace['salaire'].fillna(df_replace['salaire'].median())

# ─────────────────────────────────────────────────────────
# RÈGLE MÉTIER : VÉRIFICATION DES PLAGES VALIDES
# ─────────────────────────────────────────────────────────

# La meilleure détection est celle basée sur la connaissance métier
def valider_donnees(df):
    """Applique des règles métier pour détecter les valeurs invalides."""
    
    erreurs = {}
    
    # Age entre 18 et 100
    erreurs['age_invalide'] = df[(df['age'] < 18) | (df['age'] > 100)]
    
    # Salaire positif et < 1 million
    erreurs['salaire_invalide'] = df[(df['salaire'] < 0) | (df['salaire'] > 1_000_000)]
    
    # Expérience ne peut pas dépasser (age - 18)
    erreurs['exp_invalide'] = df[df['experience'] > (df['age'] - 18)]
    
    for nom, df_err in erreurs.items():
        print(f"\n{nom} : {len(df_err)} lignes")
        if len(df_err) > 0:
            print(df_err.head(3))
    
    return erreurs

erreurs = valider_donnees(df)
```

================================================================================
CHAPITRE 25 — NORMALISATION ET STANDARDISATION
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE LA NORMALISATION ?

La normalisation (ou mise à l'échelle) transforme les variables numériques
pour qu'elles soient comparables entre elles.

POURQUOI C'EST NÉCESSAIRE ?

Problème : Dans un dataset de prédiction de prix immobilier :
  - Surface : 50 à 500 m²
  - Nombre de pièces : 1 à 10
  - Distance centre-ville : 0 à 50 km

Si vous utilisez ces données brutes dans un modèle de ML :
  - La surface va DOMINER (300 vs 5 vs 20)
  - Le modèle sera biaisé vers les variables à grande échelle
  - Les algorithmes basés sur les distances (KNN, SVM) seront faussés

Solution : Ramener toutes les variables sur une échelle commune.

QUELS ALGORITHMES NÉCESSITENT LA NORMALISATION ?

OBLIGATOIRE :
  - KNN (distance euclidienne)
  - SVM (marges maximales)
  - Régression logistique (descente de gradient)
  - Réseaux de neurones
  - PCA (décomposition en composantes principales)
  - K-means (distances)

PAS NÉCESSAIRE :
  - Arbres de décision (comparaisons ordinales)
  - Random Forest
  - Gradient Boosting (XGBoost, LightGBM)
  - Naive Bayes

────────────────────────────────────────────────────────────────────────────────
2. THÉORIE DES MÉTHODES
────────────────────────────────────────────────────────────────────────────────

MÉTHODE 1 : Min-Max Scaling (Normalisation [0, 1])

  x_scaled = (x - x_min) / (x_max - x_min)

  - Résultat : toutes les valeurs entre 0 et 1
  - Avantage : interprétable (0=minimum, 1=maximum)
  - Inconvénient : très sensible aux outliers
    Si x_max est un outlier, toutes les autres valeurs seront écrasées vers 0

MÉTHODE 2 : StandardScaler (Z-score Standardisation)

  x_scaled = (x - μ) / σ   où μ=moyenne, σ=écart-type

  - Résultat : moyenne=0, écart-type=1
  - Avantage : robuste aux outliers (relatif au Z-score)
  - Inconvénient : ne garantit pas [0,1], peut avoir des négatifs
  - Hypothèse implicite : distribution approximativement normale

MÉTHODE 3 : RobustScaler

  x_scaled = (x - médiane) / IQR

  - Résultat : centré sur la médiane, mis à l'échelle par l'IQR
  - Avantage : TRÈS robuste aux outliers (médiane et IQR insensibles)
  - Idéal : données avec beaucoup d'outliers

MÉTHODE 4 : MaxAbsScaler

  x_scaled = x / max(|x|)

  - Résultat : [-1, 1]
  - Avantage : préserve les zéros (bon pour données éparses)

MÉTHODE 5 : Normalisation L2 (par ligne)

  x_scaled_i = x_i / ||x||₂   où ||x||₂ = sqrt(Σ xᵢ²)

  - Normalise chaque LIGNE pour que sa norme soit 1
  - Utilisé en NLP (similarité cosinus)

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 25 — NORMALISATION ET STANDARDISATION COMPLÈTES
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import (
    MinMaxScaler, StandardScaler, RobustScaler, 
    MaxAbsScaler, Normalizer, PowerTransformer
)

np.random.seed(42)

# Dataset avec échelles très différentes et outliers
df = pd.DataFrame({
    'surface_m2':       np.random.normal(100, 30, 200).clip(20),
    'nb_pieces':        np.random.randint(1, 8, 200).astype(float),
    'distance_km':      np.random.exponential(10, 200),
    'prix_eur':         np.random.normal(300000, 100000, 200).clip(50000),
    'annee_construction': np.random.randint(1950, 2024, 200).astype(float)
})

# Ajouter des outliers dans prix_eur
df.loc[0, 'prix_eur'] = 5000000   # Appartement ultra-luxe
df.loc[1, 'prix_eur'] = 10000     # Erreur de saisie

print("Statistiques avant normalisation :")
print(df.describe())

# ─────────────────────────────────────────────────────────
# APPLICATION DES SCALERS
# ─────────────────────────────────────────────────────────

X = df.copy()

scalers = {
    'MinMax [0,1]':   MinMaxScaler(),
    'Standard Z':     StandardScaler(),
    'Robust IQR':     RobustScaler(),
    'MaxAbs [-1,1]':  MaxAbsScaler()
}

results = {}
for nom, scaler in scalers.items():
    X_scaled = scaler.fit_transform(X)
    results[nom] = pd.DataFrame(X_scaled, columns=X.columns)

# ─────────────────────────────────────────────────────────
# VISUALISATION COMPARATIVE
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(5, 5, figsize=(20, 16))

for col_idx, col in enumerate(X.columns):
    # Colonne 0 : données originales
    axes[col_idx][0].hist(X[col], bins=20, color='gray', edgecolor='black', alpha=0.7)
    axes[col_idx][0].set_title(f"Original\n{col}", fontsize=8)
    
    # Colonnes 1-4 : après chaque scaler
    for scaler_idx, (nom_scaler, df_scaled) in enumerate(results.items()):
        ax = axes[col_idx][scaler_idx + 1]
        ax.hist(df_scaled[col], bins=20, color=f"C{scaler_idx}", edgecolor='black', alpha=0.7)
        ax.set_title(f"{nom_scaler}\n{col}", fontsize=7)

plt.suptitle("Comparaison des méthodes de normalisation", fontsize=13)
plt.tight_layout()
plt.savefig("normalisation_comparaison.png", dpi=120, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# TABLEAU COMPARATIF DES PROPRIÉTÉS
# ─────────────────────────────────────────────────────────

print("\nStatistiques après normalisation (colonne prix_eur) :")
print(f"{'Méthode':20s} {'Min':8s} {'Max':8s} {'Mean':8s} {'Std':8s}")
print("-" * 55)

print(f"{'Original':20s} {X['prix_eur'].min():8.0f} {X['prix_eur'].max():8.0f} "
      f"{X['prix_eur'].mean():8.0f} {X['prix_eur'].std():8.0f}")

for nom, df_scaled in results.items():
    col = df_scaled['prix_eur']
    print(f"{nom:20s} {col.min():8.3f} {col.max():8.3f} {col.mean():8.3f} {col.std():8.3f}")

# ─────────────────────────────────────────────────────────
# TRANSFORMATION DE PUISSANCE (pour distributions asymétriques)
# ─────────────────────────────────────────────────────────

# Les distributions asymétriques (long-tail) posent problème aux modèles linéaires
# PowerTransformer les rend plus "normales" (gaussiennes)

from sklearn.preprocessing import PowerTransformer

# Méthode Yeo-Johnson (fonctionne avec valeurs positives ET négatives)
pt_yj = PowerTransformer(method='yeo-johnson')

# Méthode Box-Cox (seulement valeurs positives)
# Trouve λ optimal tel que x^λ suit une distribution normale
pt_bc = PowerTransformer(method='box-cox')

# Appliquer sur distance_km (distribution exponentielle asymétrique)
X_pos = X[X['distance_km'] > 0][['distance_km']].copy()

fig, axes = plt.subplots(1, 4, figsize=(16, 4))
axes[0].hist(X_pos['distance_km'], bins=30, color='gray')
axes[0].set_title("Original\n(exponentielle)")

axes[1].hist(np.log1p(X_pos['distance_km']), bins=30, color='blue')
axes[1].set_title("Log Transform\nlog(1+x)")

X_yj = pt_yj.fit_transform(X_pos)
axes[2].hist(X_yj, bins=30, color='green')
axes[2].set_title("Yeo-Johnson\nPowerTransformer")

X_bc = pt_bc.fit_transform(X_pos)
axes[3].hist(X_bc, bins=30, color='red')
axes[3].set_title("Box-Cox\nPowerTransformer")

plt.suptitle("Transformations pour distributions asymétriques")
plt.tight_layout()
plt.savefig("power_transform.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# INTÉGRATION DANS UN PIPELINE ML COMPLET
# ─────────────────────────────────────────────────────────

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

# Ajouter une colonne catégorielle
df['type_bien'] = np.random.choice(['Appartement', 'Maison', 'Studio'], 200)

X = df.drop(columns=['prix_eur'])
y = df['prix_eur']

# Définir les colonnes par type
cols_num = ['surface_m2', 'nb_pieces', 'distance_km', 'annee_construction']
cols_cat = ['type_bien']

# Transformer les numériques et catégoriels séparément
preprocessor = ColumnTransformer(transformers=[
    ('num', StandardScaler(), cols_num),
    ('cat', OneHotEncoder(drop='first', sparse_output=False), cols_cat)
])

# Pipeline complet
pipeline = Pipeline(steps=[
    ('preprocessing', preprocessor),
    ('model', LinearRegression())
])

# Split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Entraîner
pipeline.fit(X_train, y_train)

# Évaluer
score = pipeline.score(X_test, y_test)
print(f"\nR² sur test : {score:.3f}")

# CRITIQUE : Le StandardScaler est fit() sur X_train SEULEMENT
# (grâce au pipeline). Il est ensuite appliqué (transform) sur X_test.
# -> Pas de data leakage !

# ─────────────────────────────────────────────────────────
# INVERSE TRANSFORM : RETROUVER LES VALEURS ORIGINALES
# ─────────────────────────────────────────────────────────

scaler_demo = StandardScaler()
X_num = df[cols_num].copy()
X_scaled = scaler_demo.fit_transform(X_num)

# Retrouver les valeurs originales
X_original = scaler_demo.inverse_transform(X_scaled)
df_original = pd.DataFrame(X_original, columns=cols_num)

print("\nVérification inverse_transform :")
print(f"  surface originale[0] : {df['surface_m2'].iloc[0]:.2f}")
print(f"  surface retrouvée[0] : {df_original['surface_m2'].iloc[0]:.2f}")
# Doit être identique (à l'arrondi flottant près)
```

────────────────────────────────────────────────────────────────────────────────
7. BONNES PRATIQUES — NORMALISATION
────────────────────────────────────────────────────────────────────────────────

RÈGLE D'OR : Fit sur train, transform sur test (et JAMAIS l'inverse)
  scaler.fit(X_train)
  X_train_scaled = scaler.transform(X_train)
  X_test_scaled  = scaler.transform(X_test)   # Pas de fit ici !

GUIDE DE CHOIX :
  - Distribution normale, peu d'outliers   -> StandardScaler
  - Beaucoup d'outliers                    -> RobustScaler
  - Besoin de [0,1] strict                 -> MinMaxScaler
  - Données éparses (beaucoup de zéros)    -> MaxAbsScaler
  - Distribution très asymétrique          -> PowerTransformer ou log

NORMALISER LES CIBLES (y) ?
  - Régression : parfois utile (unités très grandes)
  - Classification : jamais (y est catégoriel)

NE PAS NORMALISER :
  - Variables binaires (0/1)
  - Variables catégorielles (encoder d'abord)
  - Variables cibles pour la classification

────────────────────────────────────────────────────────────────────────────────
9. EXERCICES PRATIQUES — CHAPITRES 24 & 25
────────────────────────────────────────────────────────────────────────────────

── OUTLIERS — FACILE ──

Ex 24.1 : Détectez les outliers dans la colonne 'fare' du dataset Titanic
  par la méthode IQR. Combien y en a-t-il ?

Ex 24.2 : Créez un boxplot pour les colonnes numériques du dataset Iris.
  Identifiez visuellement les outliers.

── OUTLIERS — INTERMÉDIAIRE ──

Ex 24.3 : Comparez le résultat d'un modèle Linear Regression sur salaires
  avec et sans winsorization [1%, 99%]. Quel R² obtient-on ?

── NORMALISATION — FACILE ──

Ex 25.1 : Normalisez le dataset Iris (4 features) avec MinMaxScaler et
  StandardScaler. Comparez les statistiques avant/après.

Ex 25.2 : Appliquez un log transform sur la colonne 'SalePrice' du dataset
  Ames Housing. Comparez le skewness avant/après.

── NORMALISATION — AVANCÉ ──

Ex 25.3 : Créez un pipeline complet avec ColumnTransformer qui :
  - Impute les NaN numériques par médiane
  - Applique RobustScaler sur numériques
  - OneHotEncode les catégoriels
  - Entraîne un KNN Regressor
  - Affiche le score R² en cross-validation (cv=5)

────────────────────────────────────────────────────────────────────────────────
10. CORRIGÉS
────────────────────────────────────────────────────────────────────────────────

```python
# ── CORRIGÉ 24.1 ──
import seaborn as sns
titanic = sns.load_dataset('titanic')

Q1 = titanic['fare'].quantile(0.25)
Q3 = titanic['fare'].quantile(0.75)
IQR = Q3 - Q1
borne_sup = Q3 + 1.5 * IQR
borne_inf = Q1 - 1.5 * IQR

outliers = (titanic['fare'] < borne_inf) | (titanic['fare'] > borne_sup)
print(f"Outliers fare : {outliers.sum()}")
print(f"Fare max: {titanic['fare'].max():.2f}")
print(f"Borne sup: {borne_sup:.2f}")

# ── CORRIGÉ 25.1 ──
from sklearn.datasets import load_iris
from sklearn.preprocessing import MinMaxScaler, StandardScaler
import pandas as pd

iris = load_iris()
X = pd.DataFrame(iris.data, columns=iris.feature_names)

print("AVANT normalisation :")
print(X.describe())

scaler_mm = MinMaxScaler()
X_mm = pd.DataFrame(scaler_mm.fit_transform(X), columns=X.columns)
print("\nAprès MinMaxScaler :")
print(X_mm.describe())

scaler_std = StandardScaler()
X_std = pd.DataFrame(scaler_std.fit_transform(X), columns=X.columns)
print("\nAprès StandardScaler :")
print(X_std.describe())

# ── CORRIGÉ 25.3 ──
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import RobustScaler, OneHotEncoder
from sklearn.neighbors import KNeighborsRegressor
from sklearn.model_selection import cross_val_score

np.random.seed(42)
n = 300
X_demo = pd.DataFrame({
    'surface': np.random.normal(100, 30, n),
    'pieces': np.random.randint(1, 8, n).astype(float),
    'type': np.random.choice(['Appt', 'Maison'], n),
})
# Introduire NaN
mask = np.random.random(n) < 0.1
X_demo.loc[mask, 'surface'] = np.nan
y_demo = X_demo['surface'].fillna(100) * 3000 + np.random.normal(0, 10000, n)

cols_num = ['surface', 'pieces']
cols_cat = ['type']

preprocessor = ColumnTransformer([
    ('num', Pipeline([
        ('imputer', SimpleImputer(strategy='median')),
        ('scaler', RobustScaler())
    ]), cols_num),
    ('cat', OneHotEncoder(drop='first', sparse_output=False), cols_cat)
])

pipeline = Pipeline([
    ('prep', preprocessor),
    ('model', KNeighborsRegressor(n_neighbors=5))
])

scores = cross_val_score(pipeline, X_demo, y_demo, cv=5, scoring='r2')
print(f"R² moyen (CV=5) : {scores.mean():.3f} ± {scores.std():.3f}")
```

================================================================================
     RÉSUMÉ PARTIE 5 — NETTOYAGE DES DONNÉES
================================================================================

CHAPITRE 22 — DONNÉES MANQUANTES :
  [OK] 3 mécanismes : MCAR, MAR, MNAR
  [OK] Détection : isnull(), heatmap, audit_missing()
  [OK] Stratégies : dropna, fillna, KNN, MICE, indicateur
  [OK] Règle d'or : fit sur train, transform sur test

CHAPITRE 23 — DOUBLONS :
  [OK] Détection : duplicated()
  [OK] Suppression : drop_duplicates()
  [OK] Doublons flous : normalisation + fuzzywuzzy
  [OK] Déduplication avec agrégation

CHAPITRE 24 — OUTLIERS :
  [OK] Z-score (|z| > 3)
  [OK] IQR (Tukey, facteur 1.5)
  [OK] Isolation Forest (multivarié)
  [OK] Traitements : suppression, winsorization, log, remplacement

CHAPITRE 25 — NORMALISATION :
  [OK] MinMaxScaler [0,1]
  [OK] StandardScaler (z-score)
  [OK] RobustScaler (médiane/IQR)
  [OK] PowerTransformer (distributions asymétriques)
  [OK] Règle d'or : fit UNIQUEMENT sur train

================================================================================
FIN PARTIE 5
================================================================================

================================================================================
     GUIDE COMPLET DATA SCIENCE AVEC PYTHON — PARTIE 6
     ANALYSE EXPLORATOIRE DES DONNÉES (EDA)
     Chapitres 26 à 29
================================================================================

"Avant de modéliser, il faut comprendre. Avant de comprendre, il faut explorer."
                                                              — Tout bon data scientist

================================================================================
CHAPITRE 26 — STATISTIQUES DESCRIPTIVES
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE L'EDA ?

L'Exploratory Data Analysis (EDA) — Analyse Exploratoire des Données — est
la phase d'investigation préliminaire d'un dataset. Elle précède toujours la
modélisation et vise à :

1. Comprendre la structure des données
2. Détecter les anomalies (outliers, NaN, doublons)
3. Identifier les relations entre variables
4. Générer des hypothèses
5. Choisir les bonnes transformations et modèles

QU'EST-CE QUE LES STATISTIQUES DESCRIPTIVES ?

Les statistiques descriptives RÉSUMENT et DÉCRIVENT un dataset avec des
chiffres clés. Contrairement aux statistiques inférentielles (qui font des
prédictions), elles ne font que décrire ce qu'on observe.

CATÉGORIES :
  - Mesures de tendance centrale (où se situe le "centre")
  - Mesures de dispersion (comment les données sont réparties)
  - Mesures de forme (symétrie, aplatissement)
  - Mesures de position (percentiles, quartiles)

────────────────────────────────────────────────────────────────────────────────
2. THÉORIE ULTRA DÉTAILLÉE
────────────────────────────────────────────────────────────────────────────────

═══════════════════════════════════════════════════
MESURES DE TENDANCE CENTRALE
═══════════════════════════════════════════════════

━━━ MOYENNE ARITHMÉTIQUE (Mean) ━━━

Formule : μ = (1/n) × Σᵢ xᵢ = (x₁ + x₂ + ... + xₙ) / n

Exemple : Salaires [30k, 40k, 35k, 38k, 200k]
  Moyenne = (30+40+35+38+200) / 5 = 343/5 = 68.6k

Problème : La moyenne est sensible aux outliers.
  Sans le 200k -> moyenne = 35.75k (plus représentative !)

Quand l'utiliser :
  [OK] Distribution symétrique, sans outliers (revenus dans une entreprise homogène)
  [X] Distributions asymétriques (richesse mondiale, salaires au sens large)

━━━ MÉDIANE (Median) ━━━

Définition : Valeur du milieu quand les données sont triées.

Calcul :
  n impair -> médiane = valeur en position (n+1)/2
  n pair   -> médiane = moyenne des valeurs en positions n/2 et n/2+1

Exemple : [30k, 35k, 38k, 40k, 200k] -> médiane = 38k (position 3)
  Robuste ! Le 200k ne change rien.

Quand l'utiliser :
  [OK] Distributions asymétriques (revenus, prix immobiliers)
  [OK] Présence d'outliers
  [OK] Données ordinales

━━━ MODE ━━━

Définition : La valeur qui apparaît le plus fréquemment.

Exemple : [rouge, bleu, rouge, vert, rouge] -> mode = rouge

Particularités :
  - Peut être multimodal (plusieurs modes)
  - Seule mesure valide pour les variables nominales
  - Peut ne pas exister (distribution uniforme)

━━━ MOYENNE GÉOMÉTRIQUE ━━━

Formule : G = (x₁ × x₂ × ... × xₙ)^(1/n) = exp(mean(log(x)))

Utilisation : Taux de croissance, retours financiers, ratios
Exemple : Portefeuille avec rendements annuels +50%, -20%, +30%
  Moyenne arithmétique = 20% (FAUSSE pour les rendements)
  Moyenne géométrique  = (1.5 × 0.8 × 1.3)^(1/3) - 1 ≈ 17.8% (VRAIE)

━━━ MOYENNE HARMONIQUE ━━━

Formule : H = n / Σ(1/xᵢ)

Utilisation : Vitesses, débits, ratios (P/E financier)
Exemple : Vitesse moyenne de 60 km/h à l'aller, 40 km/h au retour
  Arithmétique : (60+40)/2 = 50 km/h (FAUSSE)
  Harmonique   : 2 / (1/60 + 1/40) = 48 km/h (VRAIE)

═══════════════════════════════════════════════════
MESURES DE DISPERSION
═══════════════════════════════════════════════════

━━━ VARIANCE ━━━

Variance populationnelle : σ² = (1/n) × Σ(xᵢ - μ)²
Variance échantillonnale : s² = (1/(n-1)) × Σ(xᵢ - x̄)²

Pourquoi n-1 et non n ? (Correction de Bessel)
  Avec n : on SOUS-estime la vraie variance de la population
  Avec n-1 : estimateur non biaisé (Bessel prouvé en 1823)
  -> C'est ce que Python/Pandas utilise par défaut (ddof=1)

Unité : Carré de l'unité originale (km² si la variable est en km)
  -> Difficile à interpréter directement

━━━ ÉCART-TYPE (Standard Deviation) ━━━

Formule : σ = √(Variance)

Avantage : Même unité que la variable originale -> interprétable !

Interprétation (distribution normale) :
  μ ± 1σ -> 68.27% des données
  μ ± 2σ -> 95.45% des données
  μ ± 3σ -> 99.73% des données

━━━ IQR (Interquartile Range) ━━━

Formule : IQR = Q3 - Q1 = P75 - P25

Avantage : ROBUSTE aux outliers (Q1 et Q3 ignorent les extrêmes)
Utilisation : Définir les moustaches du boxplot

━━━ COEFFICIENT DE VARIATION (CV) ━━━

Formule : CV = (σ / μ) × 100%

Permet de comparer la dispersion de variables d'unités différentes.
Exemple : Comparer la variabilité des salaires (€) et des notes (/20)
  Salaires : μ=40k, σ=8k -> CV = 20%
  Notes    : μ=12, σ=3   -> CV = 25% (plus variable proportionnellement)

═══════════════════════════════════════════════════
MESURES DE FORME
═══════════════════════════════════════════════════

━━━ SKEWNESS (Asymétrie) ━━━

Mesure la symétrie de la distribution.

Formule : Skew = E[(x-μ)³] / σ³

Interprétation :
  Skew ≈ 0    -> Distribution symétrique (normale)
  Skew > 0    -> Queue à droite (right-skewed / positively skewed)
                Exemple : Revenus (beaucoup de pauvres, peu de milliardaires)
  Skew < 0    -> Queue à gauche (left-skewed / negatively skewed)
                Exemple : Notes d'examen facile (beaucoup de bonnes notes)

Seuils pratiques :
  |Skew| < 0.5  -> Approximativement symétrique
  0.5 < |Skew| < 1 -> Modérément asymétrique
  |Skew| > 1    -> Très asymétrique

━━━ KURTOSIS (Aplatissement) ━━━

Mesure l'épaisseur des queues de distribution.

Formule : Kurt = E[(x-μ)⁴] / σ⁴

Interprétation (excès de kurtosis = kurtosis - 3) :
  Kurt ≈ 0     -> Mésokurtique (distribution normale)
  Kurt > 0     -> Leptokurtique : queues épaisses, pic central aigu
                 Exemple : marchés financiers (crashs fréquents)
  Kurt < 0     -> Platykurtique : queues fines, distribution aplatie
                 Exemple : distribution uniforme

═══════════════════════════════════════════════════
MESURES DE POSITION
═══════════════════════════════════════════════════

Percentile Pₖ : k% des données sont en-dessous de cette valeur
  P25 = Q1 (premier quartile)
  P50 = Q2 = Médiane
  P75 = Q3 (troisième quartile)
  P90 = 90ème percentile (90% des valeurs en-dessous)

Interquartile Range : IQR = P75 - P25

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 26 — STATISTIQUES DESCRIPTIVES COMPLÈTES
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings
warnings.filterwarnings('ignore')

# ─────────────────────────────────────────────────────────
# CHARGEMENT D'UN DATASET RÉEL — DIAMONDS
# ─────────────────────────────────────────────────────────

diamonds = sns.load_dataset('diamonds')
print("Dataset Diamonds :")
print(f"Shape : {diamonds.shape}")
print(f"Colonnes : {diamonds.columns.tolist()}")
print(diamonds.head())
print(diamonds.dtypes)

# ─────────────────────────────────────────────────────────
# INSPECTION INITIALE COMPLÈTE
# ─────────────────────────────────────────────────────────

def rapport_initial(df, nom="Dataset"):
    """Rapport d'audit initial complet d'un DataFrame."""
    print(f"\n{'='*65}")
    print(f"RAPPORT INITIAL — {nom}")
    print(f"{'='*65}")
    
    print(f"\n1. DIMENSIONS")
    print(f"   Lignes : {df.shape[0]:,}")
    print(f"   Colonnes : {df.shape[1]}")
    print(f"   Taille mémoire : {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
    
    print(f"\n2. TYPES DE DONNÉES")
    for dtype in df.dtypes.unique():
        cols = df.select_dtypes(include=[dtype]).columns.tolist()
        print(f"   {dtype} : {len(cols)} colonnes -> {cols}")
    
    print(f"\n3. DONNÉES MANQUANTES")
    manquants = df.isnull().sum()
    if manquants.sum() == 0:
        print("   [OK] Aucune donnée manquante")
    else:
        print(manquants[manquants > 0])
    
    print(f"\n4. DOUBLONS")
    n_doublons = df.duplicated().sum()
    print(f"   {n_doublons} doublons ({n_doublons/len(df)*100:.2f}%)")
    
    print(f"\n5. VARIABLES NUMÉRIQUES")
    print(df.describe().T.round(3))
    
    print(f"\n6. VARIABLES CATÉGORIELLES")
    for col in df.select_dtypes(include=['object', 'category']).columns:
        print(f"   {col} : {df[col].nunique()} valeurs uniques -> {df[col].value_counts().head(3).to_dict()}")

rapport_initial(diamonds, "Diamonds")

# ─────────────────────────────────────────────────────────
# CALCUL MANUEL DES STATISTIQUES DESCRIPTIVES
# ─────────────────────────────────────────────────────────

def statistiques_completes(series, nom="Variable"):
    """Calcule toutes les statistiques descriptives d'une variable."""
    s = series.dropna()
    
    print(f"\n{'─'*50}")
    print(f"Statistiques complètes : {nom}")
    print(f"{'─'*50}")
    
    # Tendance centrale
    print(f"n                     : {len(s):,}")
    print(f"Moyenne               : {s.mean():.4f}")
    print(f"Médiane               : {s.median():.4f}")
    print(f"Mode                  : {s.mode().iloc[0]:.4f}")
    print(f"Moy. géométrique      : {stats.gmean(s[s>0]):.4f}")
    
    # Dispersion
    print(f"\nVariance (ddof=1)     : {s.var():.4f}")
    print(f"Écart-type            : {s.std():.4f}")
    print(f"Min                   : {s.min():.4f}")
    print(f"Max                   : {s.max():.4f}")
    print(f"Étendue               : {s.max() - s.min():.4f}")
    print(f"IQR (Q3-Q1)           : {s.quantile(0.75) - s.quantile(0.25):.4f}")
    print(f"CV                    : {(s.std() / s.mean() * 100):.2f}%")
    
    # Percentiles
    print(f"\nPercentiles :")
    for p in [1, 5, 10, 25, 50, 75, 90, 95, 99]:
        print(f"  P{p:2d} : {s.quantile(p/100):.4f}")
    
    # Forme
    print(f"\nSkewness              : {s.skew():.4f}", end="")
    skew = s.skew()
    if abs(skew) < 0.5:
        print(" -> Symétrique")
    elif skew > 0:
        print(f" -> Asymétrie droite (queue à droite)")
    else:
        print(f" -> Asymétrie gauche (queue à gauche)")
    
    print(f"Kurtosis (excès)      : {s.kurtosis():.4f}", end="")
    kurt = s.kurtosis()
    if abs(kurt) < 0.5:
        print(" -> Mésokurtique (proche normale)")
    elif kurt > 0:
        print(" -> Leptokurtique (queues épaisses)")
    else:
        print(" -> Platykurtique (queues fines)")
    
    # Test de normalité
    if len(s) < 5000:
        stat, p_val = stats.shapiro(s.sample(min(len(s), 500), random_state=42))
        print(f"\nTest Shapiro-Wilk     : W={stat:.4f}, p={p_val:.4f}")
        print(f"Distribution normale  : {'NON (p<0.05)' if p_val < 0.05 else 'OUI (p>=0.05)'}")
    else:
        stat, p_val = stats.normaltest(s.sample(1000, random_state=42))
        print(f"\nTest D'Agostino      : stat={stat:.4f}, p={p_val:.4f}")
        print(f"Distribution normale  : {'NON (p<0.05)' if p_val < 0.05 else 'OUI (p>=0.05)'}")

statistiques_completes(diamonds['price'], "Prix des diamants")
statistiques_completes(diamonds['carat'], "Carat des diamants")

# ─────────────────────────────────────────────────────────
# STATISTIQUES PAR GROUPE
# ─────────────────────────────────────────────────────────

print("\nStatistiques du prix par catégorie de taille (cut) :")
stats_par_cut = diamonds.groupby('cut')['price'].agg([
    'count',
    'mean',
    'median',
    'std',
    lambda x: x.quantile(0.25),   # Q1
    lambda x: x.quantile(0.75),   # Q3
    'min',
    'max'
])
stats_par_cut.columns = ['n', 'mean', 'median', 'std', 'Q1', 'Q3', 'min', 'max']
print(stats_par_cut.round(0))

# ─────────────────────────────────────────────────────────
# TABLEAU DE FRÉQUENCES — VARIABLES CATÉGORIELLES
# ─────────────────────────────────────────────────────────

def tableau_frequences(series, top_n=None):
    """Tableau de fréquences complet pour une variable catégorielle."""
    vc = series.value_counts()
    pct = series.value_counts(normalize=True) * 100
    cum_pct = pct.cumsum()
    
    tableau = pd.DataFrame({
        'Effectif': vc,
        'Fréquence (%)': pct.round(2),
        'Fréquence cumulée (%)': cum_pct.round(2)
    })
    
    if top_n:
        tableau = tableau.head(top_n)
    
    # Ligne Total
    tableau.loc['TOTAL'] = [vc.sum(), 100.0, '-']
    
    return tableau

print("\nTableau de fréquences — Cut :")
print(tableau_frequences(diamonds['cut']))

print("\nTableau de fréquences — Color :")
print(tableau_frequences(diamonds['color']))

# ─────────────────────────────────────────────────────────
# VISUALISATION STATISTIQUES DESCRIPTIVES
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 3, figsize=(16, 10))

# 1. Histogramme avec KDE
axes[0][0].hist(diamonds['price'], bins=50, density=True, 
                color='steelblue', edgecolor='white', alpha=0.7)
diamonds['price'].plot.kde(ax=axes[0][0], color='red', linewidth=2)
axes[0][0].axvline(diamonds['price'].mean(), color='orange', linestyle='--', label=f"Moyenne={diamonds['price'].mean():.0f}")
axes[0][0].axvline(diamonds['price'].median(), color='green', linestyle='--', label=f"Médiane={diamonds['price'].median():.0f}")
axes[0][0].legend(fontsize=8)
axes[0][0].set_title("Distribution des prix")
axes[0][0].set_xlabel("Prix ($)")

# 2. Boxplot
diamonds.boxplot(column='price', by='cut', ax=axes[0][1])
axes[0][1].set_title("Prix par qualité de taille")
axes[0][1].set_xlabel("Cut")
axes[0][1].set_ylabel("Prix ($)")

# 3. Violinplot
sns.violinplot(data=diamonds, x='cut', y='price', ax=axes[0][2], palette='Set2')
axes[0][2].set_title("Violinplot prix par cut")

# 4. QQ-plot (test de normalité visuel)
stats.probplot(diamonds['price'].sample(500, random_state=42), 
               dist='norm', plot=axes[1][0])
axes[1][0].set_title("QQ-plot — Prix\n(points sur la droite = normale)")

# 5. Skewness visuelle après log transform
axes[1][1].hist(np.log(diamonds['price']), bins=50, 
                color='green', edgecolor='white', alpha=0.7, density=True)
np.log(diamonds['price']).plot.kde(ax=axes[1][1], color='red', linewidth=2)
axes[1][1].set_title(f"Log(Prix)\nSkewness={np.log(diamonds['price']).skew():.3f}")

# 6. Comparaison des mesures de tendance centrale
categories = diamonds['cut'].unique()
moyennes = diamonds.groupby('cut')['price'].mean()
medianes = diamonds.groupby('cut')['price'].median()

x = np.arange(len(categories))
width = 0.35
axes[1][2].bar(x - width/2, moyennes, width, label='Moyenne', color='blue', alpha=0.7)
axes[1][2].bar(x + width/2, medianes, width, label='Médiane', color='orange', alpha=0.7)
axes[1][2].set_xticks(x)
axes[1][2].set_xticklabels(moyennes.index, rotation=15)
axes[1][2].legend()
axes[1][2].set_title("Moyenne vs Médiane par cut\n(écart = asymétrie)")

plt.suptitle("Statistiques descriptives — Dataset Diamonds", fontsize=13)
plt.tight_layout()
plt.savefig("stats_descriptives.png", dpi=150, bbox_inches='tight')
plt.show()
```

================================================================================
CHAPITRE 27 — DISTRIBUTIONS
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QU'UNE DISTRIBUTION ?

Une distribution décrit comment les valeurs d'une variable sont réparties.
Elle répond à : "Quelle proportion des données tombe dans chaque plage de valeurs ?"

POURQUOI C'EST CRUCIAL ?

1. Le choix du modèle ML dépend de la distribution des données
2. La validation des hypothèses statistiques nécessite de vérifier la normalité
3. Les transformations (log, sqrt) dépendent de la forme de la distribution
4. Les tests statistiques ont des prérequis distributionnels

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 27 — DISTRIBUTIONS EN PROFONDEUR
# ============================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

np.random.seed(42)

# ─────────────────────────────────────────────────────────
# LES DISTRIBUTIONS THÉORIQUES PRINCIPALES
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(3, 3, figsize=(15, 12))

distributions = {
    'Normale\nμ=0, σ=1': stats.norm.rvs(loc=0, scale=1, size=2000),
    'Normale\nμ=5, σ=2': stats.norm.rvs(loc=5, scale=2, size=2000),
    'Log-normale\n(revenus)': stats.lognorm.rvs(s=0.8, loc=0, scale=1, size=2000),
    'Exponentielle\n(attentes)': stats.expon.rvs(scale=1, size=2000),
    'Uniforme\n[0, 10]': stats.uniform.rvs(loc=0, scale=10, size=2000),
    'Poisson\nλ=5': stats.poisson.rvs(mu=5, size=2000),
    'Binomiale\nn=20, p=0.5': stats.binom.rvs(n=20, p=0.5, size=2000),
    'Chi-carré\nk=5': stats.chi2.rvs(df=5, size=2000),
    'Student-t\nν=10': stats.t.rvs(df=10, size=2000)
}

for i, (nom, data) in enumerate(distributions.items()):
    ax = axes[i // 3][i % 3]
    ax.hist(data, bins=40, density=True, color=f'C{i}', edgecolor='white', alpha=0.7)
    ax.set_title(nom, fontsize=9)
    ax.set_xlabel("Valeur")
    ax.set_ylabel("Densité")
    
    # Statistiques dans le titre
    skew = pd.Series(data).skew()
    kurt = pd.Series(data).kurtosis()
    ax.text(0.95, 0.95, f"Skew={skew:.2f}\nKurt={kurt:.2f}",
            transform=ax.transAxes, ha='right', va='top', fontsize=7,
            bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))

plt.suptitle("Zoo des distributions statistiques", fontsize=13)
plt.tight_layout()
plt.savefig("distributions_zoo.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# IDENTIFICATION DE DISTRIBUTION PAR FIT
# ─────────────────────────────────────────────────────────

# Données de revenus (log-normale typique)
revenus = np.random.lognormal(mean=10.5, sigma=0.6, size=1000)

# Tester plusieurs distributions et trouver le meilleur fit
distributions_a_tester = [
    ('norm', stats.norm),
    ('lognorm', stats.lognorm),
    ('expon', stats.expon),
    ('gamma', stats.gamma),
    ('weibull_min', stats.weibull_min)
]

resultats_fit = []
for nom_dist, distribution in distributions_a_tester:
    try:
        params = distribution.fit(revenus)
        # Test de Kolmogorov-Smirnov
        D, p_val = stats.kstest(revenus, nom_dist, args=params)
        resultats_fit.append({
            'Distribution': nom_dist,
            'Statistique KS': round(D, 4),
            'p-value': round(p_val, 4),
            'Bon fit (p>0.05)': '[OK] OUI' if p_val > 0.05 else '[X] NON'
        })
    except Exception as e:
        pass

df_fit = pd.DataFrame(resultats_fit).sort_values('p-value', ascending=False)
print("Comparaison de distributions sur données de revenus :")
print(df_fit.to_string(index=False))

# ─────────────────────────────────────────────────────────
# HISTOGRAMME AVANCÉ — DISTRIBUTIONS DES DONNÉES RÉELLES
# ─────────────────────────────────────────────────────────

diamonds = sns.load_dataset('diamonds')

fig, axes = plt.subplots(2, 4, figsize=(18, 8))

# Colonnes numériques
cols_num = ['carat', 'depth', 'table', 'price', 'x', 'y', 'z']

for i, col in enumerate(cols_num):
    row, col_idx = divmod(i, 4)
    ax = axes[row][col_idx]
    
    data = diamonds[col].dropna()
    skew = data.skew()
    kurt = data.kurtosis()
    
    # Histogramme
    ax.hist(data, bins=50, density=True, color=f'C{i}', alpha=0.6, edgecolor='white')
    
    # KDE (Kernel Density Estimation)
    data.plot.kde(ax=ax, color='black', linewidth=1.5)
    
    ax.set_title(f"{col}\nSkew={skew:.2f}, Kurt={kurt:.2f}", fontsize=9)
    ax.set_xlabel("")

# Cacher le dernier subplot vide
axes[1][3].axis('off')

plt.suptitle("Distributions des variables — Dataset Diamonds", fontsize=12)
plt.tight_layout()
plt.savefig("diamonds_distributions.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# KDE — KERNEL DENSITY ESTIMATION
# ─────────────────────────────────────────────────────────

# KDE est une estimation "lisse" de la distribution
# Elle évite les artefacts des histogrammes (choix des bins)

fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# Impact du bandwidth (lissage)
from scipy.stats import gaussian_kde

data_kde = diamonds['price'].sample(500, random_state=42).values

for i, bandwidth in enumerate([0.05, 0.2, 1.0]):
    ax = axes[i]
    
    # Histogramme de référence
    ax.hist(data_kde, bins=30, density=True, alpha=0.3, color='gray', label='Histogramme')
    
    # KDE avec différents bandwidths
    kde = gaussian_kde(data_kde, bw_method=bandwidth)
    x_range = np.linspace(data_kde.min(), data_kde.max(), 200)
    ax.plot(x_range, kde(x_range), 'b-', linewidth=2, label=f'KDE bw={bandwidth}')
    
    ax.set_title(f"KDE — bandwidth={bandwidth}")
    ax.legend()

plt.suptitle("Impact du bandwidth sur le KDE")
plt.tight_layout()
plt.savefig("kde_bandwidth.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# DISTRIBUTION BIVARIÉE
# ─────────────────────────────────────────────────────────

# Joint plot = distribution marginale + distribution jointe
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

# Scatter + marginals
sample = diamonds.sample(1000, random_state=42)

axes[0].scatter(sample['carat'], sample['price'], alpha=0.3, s=5, color='steelblue')
axes[0].set_xlabel("Carat")
axes[0].set_ylabel("Prix ($)")
axes[0].set_title("Scatter : Carat vs Prix")

# Hexbin (densité 2D)
hb = axes[1].hexbin(sample['carat'], sample['price'], gridsize=25, cmap='Blues')
plt.colorbar(hb, ax=axes[1], label="Fréquence")
axes[1].set_xlabel("Carat")
axes[1].set_ylabel("Prix ($)")
axes[1].set_title("Hexbin density")

# KDE 2D
sns.kdeplot(data=sample, x='carat', y='price', fill=True, 
            cmap='Reds', ax=axes[2])
axes[2].set_title("KDE 2D")

plt.suptitle("Visualisation des distributions bivariées")
plt.tight_layout()
plt.savefig("distribution_bivariee.png", dpi=150, bbox_inches='tight')
plt.show()
```

================================================================================
CHAPITRE 28 — CORRÉLATIONS
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE LA CORRÉLATION ?

La corrélation mesure la FORCE et la DIRECTION de la relation linéaire entre
deux variables numériques.

DIFFÉRENCE CORRÉLATION ≠ CAUSALITÉ

Exemple classique : La consommation de glace corrèle avec les noyades.
-> Ce n'est pas la glace qui tue : c'est la chaleur (variable cachée confondante).

"Correlation is not causation" est l'un des principes les plus importants
en analyse de données.

────────────────────────────────────────────────────────────────────────────────
2. THÉORIE DES CORRÉLATIONS
────────────────────────────────────────────────────────────────────────────────

━━━ CORRÉLATION DE PEARSON ━━━

Formule : r = Cov(X,Y) / (σ_X × σ_Y)
         = Σ(xᵢ - x̄)(yᵢ - ȳ) / √[Σ(xᵢ-x̄)² × Σ(yᵢ-ȳ)²]

Plage : r ∈ [-1, 1]
  r = +1  -> Corrélation parfaite positive (x^ -> y^)
  r = -1  -> Corrélation parfaite négative (x^ -> yv)
  r = 0   -> Aucune corrélation linéaire

Interprétation pratique :
  |r| > 0.7  -> Forte corrélation
  |r| > 0.5  -> Corrélation modérée
  |r| > 0.3  -> Faible corrélation
  |r| < 0.3  -> Corrélation négligeable

ATTENTION : Pearson mesure UNIQUEMENT la relation LINÉAIRE.
  Deux variables peuvent être parfaitement liées (ex: y = x²) et avoir r = 0 !

━━━ CORRÉLATION DE SPEARMAN ━━━

Formule : ρ = 1 - (6 × Σdᵢ²) / (n × (n²-1))
          où dᵢ = rang(xᵢ) - rang(yᵢ)

Principe : Applique Pearson sur les RANGS plutôt que les valeurs.

Avantages :
  [OK] Robuste aux outliers (travaille sur les rangs)
  [OK] Capture les relations MONOTONES (pas seulement linéaires)
  [OK] Valide pour les données ordinales

Quand utiliser Spearman vs Pearson ?
  - Distribution non normale -> Spearman
  - Outliers présents -> Spearman
  - Variables ordinales -> Spearman
  - Relation strictement linéaire attendue -> Pearson

━━━ CORRÉLATION DE KENDALL-TAU ━━━

Mesure la concordance des paires.
Paire concordante : xᵢ > xⱼ ET yᵢ > yⱼ
Paire discordante : xᵢ > xⱼ ET yᵢ < yⱼ

τ = (concordantes - discordantes) / total_paires

Avantage : Plus robuste que Spearman pour les petits échantillons.
Inconvénient : Plus lent à calculer (O(n²)).

━━━ CORRÉLATION CRAMER'S V (catégoriel vs catégoriel) ━━━

V = √(χ² / (n × min(k-1, r-1)))
  où χ² = test du chi-carré, k = nb colonnes, r = nb lignes

Plage : [0, 1]
  0 = indépendance totale
  1 = association parfaite

━━━ CORRÉLATION POINT-BISÉRIALE (numérique vs binaire) ━━━

rpb = (μ₁ - μ₀) / σ_total × √(n₁×n₀/n²)

Utilisé pour comparer une variable continue entre 2 groupes.

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 28 — ANALYSE DES CORRÉLATIONS COMPLÈTE
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

# Dataset Diamonds
diamonds = sns.load_dataset('diamonds')
cols_num = ['carat', 'depth', 'table', 'price', 'x', 'y', 'z']
X_num = diamonds[cols_num]

# ─────────────────────────────────────────────────────────
# MATRICE DE CORRÉLATION PEARSON
# ─────────────────────────────────────────────────────────

corr_pearson = X_num.corr(method='pearson')
print("Matrice de corrélation Pearson :")
print(corr_pearson.round(3))

# Heatmap de corrélation
fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Pearson
mask_upper = np.triu(np.ones_like(corr_pearson, dtype=bool), k=1)
sns.heatmap(
    corr_pearson,
    mask=mask_upper,         # Cacher le triangle supérieur (symétrie)
    annot=True,              # Afficher les valeurs
    fmt='.2f',               # Format 2 décimales
    cmap='coolwarm',         # Rouge=positif, Bleu=négatif
    center=0,                # Centre de la palette à 0
    square=True,             # Carrés parfaits
    linewidths=0.5,          # Séparateurs entre cellules
    ax=axes[0],
    vmin=-1, vmax=1          # Plage fixe pour la cohérence
)
axes[0].set_title("Corrélation Pearson\n(Triangle inférieur)")

# Spearman
corr_spearman = X_num.corr(method='spearman')
sns.heatmap(
    corr_spearman,
    mask=mask_upper,
    annot=True,
    fmt='.2f',
    cmap='coolwarm',
    center=0,
    square=True,
    linewidths=0.5,
    ax=axes[1],
    vmin=-1, vmax=1
)
axes[1].set_title("Corrélation Spearman\n(Triangle inférieur)")

plt.tight_layout()
plt.savefig("correlation_matrices.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# TESTS DE SIGNIFICATION DES CORRÉLATIONS
# ─────────────────────────────────────────────────────────

def matrice_correlation_avec_pvalues(df, method='pearson', alpha=0.05):
    """
    Calcule la matrice de corrélation avec les p-values.
    Retourne les corrélations et un masque des corrélations significatives.
    """
    n = len(df.columns)
    cols = df.columns
    
    r_matrix = pd.DataFrame(np.zeros((n, n)), columns=cols, index=cols)
    p_matrix = pd.DataFrame(np.ones((n, n)), columns=cols, index=cols)
    
    for i, col1 in enumerate(cols):
        for j, col2 in enumerate(cols):
            if i == j:
                r_matrix.loc[col1, col2] = 1.0
                p_matrix.loc[col1, col2] = 0.0
            elif i < j:
                if method == 'pearson':
                    r, p = stats.pearsonr(df[col1].dropna(), df[col2].dropna())
                elif method == 'spearman':
                    r, p = stats.spearmanr(df[col1].dropna(), df[col2].dropna())
                
                r_matrix.loc[col1, col2] = r
                r_matrix.loc[col2, col1] = r
                p_matrix.loc[col1, col2] = p
                p_matrix.loc[col2, col1] = p
    
    print(f"\nCorrélations significatives (p < {alpha}) :")
    for i, col1 in enumerate(cols):
        for j, col2 in enumerate(cols):
            if i < j:
                r = r_matrix.loc[col1, col2]
                p = p_matrix.loc[col1, col2]
                if p < alpha:
                    significatif = "[OK]"
                    force = "forte" if abs(r) > 0.7 else "modérée" if abs(r) > 0.4 else "faible"
                    direction = "positive" if r > 0 else "négative"
                    print(f"  {col1:10s} <-> {col2:10s} : r={r:.3f} ({force}, {direction}), p={p:.2e} {significatif}")
    
    return r_matrix, p_matrix

r_mat, p_mat = matrice_correlation_avec_pvalues(X_num.sample(1000, random_state=42))

# ─────────────────────────────────────────────────────────
# SCATTER MATRIX (PAIR PLOT)
# ─────────────────────────────────────────────────────────

# Pair plot = grille de scatter plots pour toutes les paires de variables
sample_d = diamonds.sample(500, random_state=42)

g = sns.pairplot(
    sample_d[cols_num + ['cut']],
    hue='cut',                    # Coloration par catégorie
    diag_kind='kde',              # KDE sur la diagonale (distribution)
    plot_kws={'alpha': 0.3, 's': 10},  # Transparence et taille des points
    diag_kws={'alpha': 0.5}
)
g.fig.suptitle("Pair Plot — Diamonds (coloré par cut)", y=1.02)
plt.savefig("pairplot_diamonds.png", dpi=100, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# CORRÉLATION AVEC VARIABLE CIBLE
# ─────────────────────────────────────────────────────────

# Corrélation de chaque feature avec le prix
print("\nCorrélation de chaque feature avec le prix (Pearson) :")
corr_avec_prix = X_num.corr()['price'].drop('price').sort_values(key=abs, ascending=False)
print(corr_avec_prix)

# Visualisation
plt.figure(figsize=(8, 5))
corr_avec_prix.plot(kind='barh', color=['green' if v > 0 else 'red' for v in corr_avec_prix])
plt.axvline(0, color='black', linewidth=0.8)
plt.title("Corrélation de chaque variable avec le prix")
plt.xlabel("Corrélation de Pearson")
plt.tight_layout()
plt.savefig("correlation_avec_prix.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# CORRÉLATION CATÉGORIELLE — CRAMER'S V
# ─────────────────────────────────────────────────────────

from scipy.stats import chi2_contingency

def cramers_v(x, y):
    """
    Calcule le V de Cramer entre deux variables catégorielles.
    Mesure l'association entre 0 (indépendance) et 1 (association parfaite).
    """
    confusion_matrix = pd.crosstab(x, y)
    chi2, p, dof, expected = chi2_contingency(confusion_matrix)
    n = confusion_matrix.sum().sum()
    
    # V de Cramer
    min_dim = min(confusion_matrix.shape) - 1   # min(lignes-1, colonnes-1)
    V = np.sqrt(chi2 / (n * min_dim))
    
    return V, p

# Corrélation entre variables catégorielles du dataset Diamonds
cols_cat = ['cut', 'color', 'clarity']
print("\nMatrice V de Cramer (variables catégorielles) :")

cramer_matrix = pd.DataFrame(index=cols_cat, columns=cols_cat)
for col1 in cols_cat:
    for col2 in cols_cat:
        if col1 == col2:
            cramer_matrix.loc[col1, col2] = 1.0
        else:
            v, p = cramers_v(diamonds[col1], diamonds[col2])
            cramer_matrix.loc[col1, col2] = v
            
cramer_matrix = cramer_matrix.astype(float)
print(cramer_matrix.round(3))

plt.figure(figsize=(6, 4))
sns.heatmap(cramer_matrix, annot=True, fmt='.3f', cmap='Blues', vmin=0, vmax=1)
plt.title("V de Cramer — Variables catégorielles")
plt.tight_layout()
plt.savefig("cramers_v_matrix.png", dpi=150, bbox_inches='tight')
plt.show()
```

================================================================================
CHAPITRE 29 — INSIGHTS ET RAPPORT EDA
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QU'UN INSIGHT ?

Un insight est une DÉCOUVERTE ACTIONNABLE extraite des données.
Ce n'est pas juste un fait, c'est un fait + son implication business.

FAIT : "Les diamants Fair ont en moyenne un prix plus élevé que les Good."
INSIGHT : "La qualité de taille (cut) seule n'explique pas le prix — les
           diamants mal taillés sont souvent plus gros (carat plus élevé),
           ce qui compense et dépasse l'effet négatif de la taille médiocre."

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION — RAPPORT EDA AUTOMATISÉ
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 29 — EDA COMPLÈTE ET AUTOMATISÉE
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings
warnings.filterwarnings('ignore')

# ─────────────────────────────────────────────────────────
# DATASET RÉEL : TIPS (pourboires au restaurant)
# ─────────────────────────────────────────────────────────

tips = sns.load_dataset('tips')
print("Dataset Tips :")
print(tips.head())
print(tips.info())

# ─────────────────────────────────────────────────────────
# FONCTION EDA AUTOMATISÉE
# ─────────────────────────────────────────────────────────

def eda_automatique(df, cible=None, nom_dataset="Dataset"):
    """
    Effectue une EDA complète et affiche un rapport.
    
    Paramètres :
    - df : DataFrame à analyser
    - cible : Colonne cible (optionnel)
    - nom_dataset : Nom pour les titres
    """
    
    print(f"\n{'#'*70}")
    print(f"# EDA AUTOMATIQUE — {nom_dataset}")
    print(f"{'#'*70}")
    
    # ─── SECTION 1 : VUE D'ENSEMBLE ───
    print(f"\n[1] VUE D'ENSEMBLE")
    print(f"  {df.shape[0]:,} lignes × {df.shape[1]} colonnes")
    print(f"  {df.isnull().sum().sum()} valeurs manquantes")
    print(f"  {df.duplicated().sum()} doublons")
    print(f"  Mémoire : {df.memory_usage(deep=True).sum() / 1024:.1f} KB")
    
    # ─── SECTION 2 : VARIABLES NUMÉRIQUES ───
    cols_num = df.select_dtypes(include=[np.number]).columns.tolist()
    cols_cat = df.select_dtypes(include=['object', 'category', 'bool']).columns.tolist()
    
    print(f"\n[2] VARIABLES NUMÉRIQUES ({len(cols_num)}) : {cols_num}")
    stats_num = df[cols_num].describe().T
    stats_num['skew'] = df[cols_num].skew()
    stats_num['kurt'] = df[cols_num].kurtosis()
    stats_num['missing%'] = (df[cols_num].isnull().mean() * 100).round(2)
    print(stats_num.round(3))
    
    print(f"\n[3] VARIABLES CATÉGORIELLES ({len(cols_cat)}) : {cols_cat}")
    for col in cols_cat:
        vc = df[col].value_counts()
        print(f"\n  {col} ({df[col].nunique()} valeurs uniques) :")
        for val, count in vc.head(5).items():
            print(f"    {str(val):20s} : {count:4d} ({count/len(df)*100:.1f}%)")
    
    # ─── SECTION 3 : CORRÉLATIONS ───
    if len(cols_num) > 1:
        print(f"\n[4] TOP CORRÉLATIONS PEARSON")
        corr = df[cols_num].corr()
        # Extraire les paires uniques (triangle supérieur)
        paires = []
        for i in range(len(cols_num)):
            for j in range(i+1, len(cols_num)):
                c1, c2 = cols_num[i], cols_num[j]
                r = corr.loc[c1, c2]
                paires.append((abs(r), r, c1, c2))
        
        paires.sort(reverse=True)
        for abs_r, r, c1, c2 in paires[:5]:
            direction = "^^" if r > 0 else "^v"
            print(f"  {c1:15s} {direction} {c2:15s} : r={r:.3f}")
    
    # ─── SECTION 4 : INSIGHTS BASIQUES AUTOMATIQUES ───
    print(f"\n[5] INSIGHTS AUTOMATIQUES")
    
    for col in cols_num:
        skew = df[col].skew()
        if abs(skew) > 1:
            direction = "droite" if skew > 0 else "gauche"
            print(f"  [ATTENTION]  {col} très asymétrique (skew={skew:.2f}, queue à {direction}) -> log transform conseillé")
        
        pct_missing = df[col].isnull().mean() * 100
        if pct_missing > 0:
            print(f"  [ATTENTION]  {col} : {pct_missing:.1f}% de données manquantes")
    
    for col in cols_cat:
        if df[col].nunique() > 20:
            print(f"  [ATTENTION]  {col} : {df[col].nunique()} modalités — high cardinality, attention à l'encodage")
        
        pct_dominant = df[col].value_counts(normalize=True).iloc[0] * 100
        if pct_dominant > 80:
            print(f"  [ATTENTION]  {col} : {pct_dominant:.1f}% de la modalité dominante — classe déséquilibrée")
    
    # ─── SECTION 5 : ANALYSE PAR RAPPORT À LA CIBLE ───
    if cible and cible in df.columns:
        print(f"\n[6] ANALYSE PAR RAPPORT À LA CIBLE : {cible}")
        
        if df[cible].dtype in [np.float64, np.int64]:
            # Cible numérique -> corrélations
            corr_cible = df[cols_num].corr()[cible].drop(cible).sort_values(key=abs, ascending=False)
            print(f"\n  Corrélations avec {cible} :")
            for feat, r in corr_cible.items():
                print(f"    {feat:15s} : r={r:.3f}")
        else:
            # Cible catégorielle -> distribution des features par classe
            print(f"\n  Distribution de la cible :")
            print(df[cible].value_counts(normalize=True).round(3))

# Exécuter l'EDA
eda_automatique(tips, cible='tip', nom_dataset="Tips (Pourboires)")

# ─────────────────────────────────────────────────────────
# DASHBOARD EDA VISUEL COMPLET
# ─────────────────────────────────────────────────────────

fig = plt.figure(figsize=(20, 16))
fig.suptitle("Dashboard EDA — Dataset Tips", fontsize=16, y=0.98)

# 1. Distribution de la variable cible (tip)
ax1 = plt.subplot(4, 4, 1)
tips['tip'].hist(bins=30, ax=ax1, color='steelblue', edgecolor='white')
ax1.set_title(f"Distribution Tip\nskew={tips['tip'].skew():.2f}")

# 2. Distribution total_bill
ax2 = plt.subplot(4, 4, 2)
tips['total_bill'].hist(bins=30, ax=ax2, color='orange', edgecolor='white')
ax2.set_title("Distribution Total Bill")

# 3. Tip par jour
ax3 = plt.subplot(4, 4, 3)
tips.groupby('day')['tip'].mean().sort_values(ascending=False).plot(kind='bar', ax=ax3, color='green')
ax3.set_title("Tip moyen par jour")
ax3.set_xlabel("")

# 4. Tip par taille du groupe
ax4 = plt.subplot(4, 4, 4)
tips.groupby('size')['tip'].mean().plot(kind='bar', ax=ax4, color='purple')
ax4.set_title("Tip moyen par taille groupe")

# 5. Scatter total_bill vs tip
ax5 = plt.subplot(4, 4, 5)
colors = {'Lunch': 'blue', 'Dinner': 'red'}
for time_val, group in tips.groupby('time'):
    ax5.scatter(group['total_bill'], group['tip'], 
                label=time_val, alpha=0.5, s=20, 
                color=colors.get(time_val, 'gray'))
# Ligne de tendance
z = np.polyfit(tips['total_bill'], tips['tip'], 1)
p = np.poly1d(z)
x_line = np.linspace(tips['total_bill'].min(), tips['total_bill'].max(), 100)
ax5.plot(x_line, p(x_line), 'k--', alpha=0.5, label='Tendance')
ax5.legend(fontsize=7)
ax5.set_title(f"Bill vs Tip\nr={tips['total_bill'].corr(tips['tip']):.3f}")
ax5.set_xlabel("Total Bill ($)")
ax5.set_ylabel("Tip ($)")

# 6. Boxplot tip par sexe
ax6 = plt.subplot(4, 4, 6)
tips.boxplot(column='tip', by='sex', ax=ax6)
ax6.set_title("Tip par sexe")
plt.sca(ax6)
plt.title("Tip par sexe")

# 7. Violin plot tip par jour
ax7 = plt.subplot(4, 4, 7)
sns.violinplot(data=tips, x='day', y='tip', ax=ax7, palette='Set2')
ax7.set_title("Violin plot Tip par jour")

# 8. Heatmap corrélation
ax8 = plt.subplot(4, 4, 8)
corr_num = tips[['total_bill', 'tip', 'size']].corr()
sns.heatmap(corr_num, annot=True, fmt='.2f', cmap='coolwarm', 
            center=0, ax=ax8, square=True)
ax8.set_title("Matrice de corrélation")

# 9. Tip% par catégorie
ax9 = plt.subplot(4, 4, 9)
tips['tip_pct'] = tips['tip'] / tips['total_bill'] * 100
tips.groupby('smoker')['tip_pct'].mean().plot(kind='bar', ax=ax9, color=['#2196F3', '#FF5722'])
ax9.set_title("Tip% moyen par fumeur")
ax9.set_xlabel("")

# 10. Distribution tip par smoker
ax10 = plt.subplot(4, 4, 10)
for smoker_val, group in tips.groupby('smoker'):
    group['tip'].plot.kde(ax=ax10, label=f"Fumeur={smoker_val}")
ax10.legend()
ax10.set_title("KDE Tip par fumeur")

# 11. Heatmap tip par jour et moment
ax11 = plt.subplot(4, 4, 11)
pivot_tip = tips.pivot_table(values='tip', index='day', columns='time', aggfunc='mean')
sns.heatmap(pivot_tip, annot=True, fmt='.2f', cmap='YlOrRd', ax=ax11)
ax11.set_title("Tip moyen\nJour × Moment")

# 12. Scatter size vs tip
ax12 = plt.subplot(4, 4, 12)
ax12.scatter(tips['size'], tips['tip'], alpha=0.4, color='green')
ax12.set_xlabel("Taille du groupe")
ax12.set_ylabel("Tip ($)")
ax12.set_title(f"Taille vs Tip\nr={tips['size'].corr(tips['tip']):.3f}")

# 13. Histogramme bill par jour
ax13 = plt.subplot(4, 4, 13)
for day_val, group in tips.groupby('day'):
    group['total_bill'].plot.kde(ax=ax13, label=day_val, alpha=0.7)
ax13.legend(fontsize=8)
ax13.set_title("Distribution Total Bill par jour")

# 14. Bar chart nombre de repas par jour
ax14 = plt.subplot(4, 4, 14)
tips['day'].value_counts().plot(kind='bar', ax=ax14, color='coral')
ax14.set_title("Nb repas par jour")
ax14.set_xlabel("")

# 15. Boxplot total_bill par cut
ax15 = plt.subplot(4, 4, 15)
tips.boxplot(column='total_bill', by='day', ax=ax15)
plt.sca(ax15)
plt.title("Total Bill par jour")

# 16. Scatter coloré
ax16 = plt.subplot(4, 4, 16)
palette = {'Male': 'blue', 'Female': 'red'}
for sex_val, group in tips.groupby('sex'):
    ax16.scatter(group['total_bill'], group['tip_pct'], 
                label=sex_val, alpha=0.5, s=15,
                color=palette.get(sex_val, 'gray'))
ax16.legend()
ax16.set_xlabel("Total Bill")
ax16.set_ylabel("Tip %")
ax16.set_title("Bill vs Tip% par sexe")

plt.tight_layout()
plt.savefig("eda_dashboard_tips.png", dpi=120, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# RAPPORT INSIGHTS — FORMAT PROFESSIONNEL
# ─────────────────────────────────────────────────────────

print("""
╔══════════════════════════════════════════════════════════╗
║         RAPPORT D'INSIGHTS — DATASET TIPS               ║
╚══════════════════════════════════════════════════════════╝

[GRAPHIQUE] CONTEXTE :
   Dataset de 244 repas dans un restaurant américain (1987).
   Variables : total_bill, tip, sex, smoker, day, time, size

[RECHERCHE] INSIGHTS CLÉS :

1. CORRÉLATION PRINCIPALE :
   Le total_bill est le meilleur prédicteur du tip (r=0.68).
   -> Le tip est quasi-proportionnel à la note.

2. PARADOXE DU FUMEUR :
   Les fumeurs laissent un tip moyen légèrement inférieur,
   mais leur tip% (tip/bill) est similaire aux non-fumeurs.
   -> La différence vient de notes totales différentes.

3. EFFET DU JOUR :
   Les dîners du samedi ont les bills ET tips les plus élevés.
   -> Effet week-end : groupes plus grands, dépenses plus élevées.

4. TAILLE DU GROUPE :
   La corrélation size<->tip est modérée (r≈0.49).
   -> Plus le groupe est grand, plus le tip est élevé en valeur absolue,
   mais le tip% diminue (effet de free-riding).

5. ASYMÉTRIE DES DONNÉES :
   La distribution de tip est asymétrique (skew > 1).
   -> Présence de généreux outliers. Utiliser médiane > moyenne.

[IDEE] RECOMMANDATIONS :
   - Utiliser total_bill + size comme features principales en ML
   - Transformer tip en log(tip) pour régression linéaire
   - Créer la feature tip_pct = tip / total_bill
""")
```

────────────────────────────────────────────────────────────────────────────────
9. EXERCICES PRATIQUES — PARTIE 6
────────────────────────────────────────────────────────────────────────────────

── NIVEAU FACILE ──

Ex 26.1 : Chargez le dataset Iris (sklearn.datasets.load_iris).
  Calculez pour chaque feature : moyenne, médiane, variance, écart-type, skewness.
  Quelle feature est la plus symétrique ? La plus dispersée (CV) ?

Ex 27.1 : Sur le dataset Titanic (sns.load_dataset('titanic')),
  tracez la distribution de l'âge avec histogramme + KDE.
  Calculez le skewness. Est-il normal ?

Ex 28.1 : Sur le dataset Penguins (sns.load_dataset('penguins')),
  calculez la matrice de corrélation Pearson.
  Quelle paire de features est la plus corrélée ? La moins ?

── NIVEAU INTERMÉDIAIRE ──

Ex 27.2 : Sur le dataset Diamonds, vérifiez si log(price) suit mieux
  une distribution normale que price. Utilisez :
  - Histogramme + KDE
  - QQ-plot
  - Test de Shapiro-Wilk

Ex 28.2 : Calculez la corrélation Pearson ET Spearman entre carat et price
  dans le dataset Diamonds. Pourquoi y a-t-il une différence ?
  Tracez le scatter plot pour comprendre.

Ex 29.1 : Réalisez une EDA complète du dataset 'mpg' (sns.load_dataset('mpg')).
  Identifiez 3 insights. Créez un dashboard de 6 graphiques.

── NIVEAU AVANCÉ ──

Ex 29.2 : Écrivez une fonction generate_eda_report(df, target) qui :
  - Génère automatiquement 12 graphiques
  - Sauvegarde un PDF multi-pages
  - Affiche les top 5 features corrélées avec target
  - Détecte automatiquement les outliers et NaN
  - Génère un texte de résumé formaté

Ex 28.3 : Réalisez une analyse de causalité vs corrélation.
  Choisissez 2 variables fortement corrélées dans Diamonds.
  Proposez 3 explications possibles : causale directe, causale inverse,
  ou variable confondante.

================================================================================
     RÉSUMÉ PARTIE 6 — ANALYSE EXPLORATOIRE
================================================================================

CHAPITRE 26 — STATISTIQUES DESCRIPTIVES :
  [OK] Tendance centrale : moyenne, médiane, mode, géométrique, harmonique
  [OK] Dispersion : variance, écart-type, IQR, CV
  [OK] Forme : skewness, kurtosis
  [OK] Tableaux de fréquences pour catégoriels
  [OK] Tests de normalité : Shapiro-Wilk, D'Agostino

CHAPITRE 27 — DISTRIBUTIONS :
  [OK] Zoo des distributions (normale, log-normale, exponentielle...)
  [OK] Fit de distribution (KS test)
  [OK] KDE et impact du bandwidth
  [OK] Distribution bivariée (hexbin, KDE 2D)

CHAPITRE 28 — CORRÉLATIONS :
  [OK] Pearson (linéaire), Spearman (monotone), Kendall-tau
  [OK] Cramer's V (catégoriel-catégoriel)
  [OK] Tests de significativité des corrélations
  [OK] Pair plots pour vision globale

CHAPITRE 29 — INSIGHTS :
  [OK] EDA automatisée avec rapport
  [OK] Dashboard visuel complet
  [OK] Distinction corrélation vs causalité
  [OK] Format professionnel d'insights

================================================================================
FIN PARTIE 6
================================================================================

================================================================================
     GUIDE COMPLET DATA SCIENCE AVEC PYTHON — PARTIE 7
     VISUALISATION DES DONNÉES
     Chapitres 30 à 32
================================================================================

"Un graphique bien fait vaut mille tableurs."
"La visualisation est le pont entre les données et la compréhension."

================================================================================
CHAPITRE 30 — MATPLOTLIB
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE MATPLOTLIB ?

Matplotlib est la bibliothèque de visualisation fondamentale de Python.
Créée en 2003 par John Hunter, elle est construite sur NumPy et constitue
la base de presque toutes les autres bibliothèques de visualisation Python
(Seaborn, Pandas .plot(), etc.).

POURQUOI MATPLOTLIB ?
  [OK] Contrôle total sur chaque élément du graphique
  [OK] Bibliothèque standard : installée dans presque tous les environnements
  [OK] Exportation en haute résolution (PNG, PDF, SVG, EPS)
  [OK] Intégration avec Jupyter, Streamlit, Flask
  [OK] Backend flexible (screen, fichier, application web)

INCONVÉNIENTS :
  [X] Syntaxe verbeuse (beaucoup de lignes pour un beau graphique)
  [X] Style par défaut vieillissant (améliorable avec plt.style)
  [X] Pas interactif nativement (Plotly est mieux pour ça)

ARCHITECTURE DE MATPLOTLIB :

  Figure (la "feuille de papier")
    └── Axes (le système de coordonnées — "le graphique")
          ├── Axis X (l'axe horizontal)
          ├── Axis Y (l'axe vertical)
          ├── Title (le titre)
          ├── Legend (la légende)
          ├── Lines, Patches, Text... (les objets visuels)
          └── Ticks (les graduations)

DEUX INTERFACES :
  1. Interface pyplot (fonctionnelle, style MATLAB) -> plt.plot(), plt.show()
  2. Interface orientée objet -> fig, ax = plt.subplots() -> ax.plot()
  
  -> TOUJOURS préférer l'interface OO en production (plus claire, plus contrôlable)

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE ULTRA COMMENTÉE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 30 — MATPLOTLIB MAÎTRISE COMPLÈTE
# ============================================================

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.gridspec as gridspec
import numpy as np
import pandas as pd

# Configurer le style global
plt.style.use('seaborn-v0_8-whitegrid')   # Style propre et moderne
# Autres styles populaires : 'dark_background', 'ggplot', 'fivethirtyeight'

# ─────────────────────────────────────────────────────────
# SECTION 1 : ANATOMIE D'UNE FIGURE MATPLOTLIB
# ─────────────────────────────────────────────────────────

# Interface Orientée Objet (recommandée)
fig, ax = plt.subplots(figsize=(10, 6))
# fig = Figure (le conteneur global)
# ax  = Axes (le système de coordonnées — c'est "le graphique")
# figsize = (largeur, hauteur) en pouces

# Données
x = np.linspace(0, 2 * np.pi, 100)
y_sin = np.sin(x)
y_cos = np.cos(x)

# Tracer des lignes
ax.plot(x, y_sin, 
        color='steelblue',        # Couleur de la ligne
        linewidth=2,              # Épaisseur
        linestyle='-',            # '-', '--', ':', '-.'
        marker='o',               # Marqueur aux points : 'o', 's', '^', 'D', '*'
        markersize=4,             # Taille des marqueurs
        markevery=10,             # Marqueur 1 point sur 10
        label='sin(x)',           # Nom pour la légende
        alpha=0.9                 # Transparence [0=transparent, 1=opaque]
       )

ax.plot(x, y_cos,
        color='coral',
        linewidth=2,
        linestyle='--',
        label='cos(x)'
       )

# Zone colorée (fill_between)
ax.fill_between(x, y_sin, y_cos, 
                where=(y_sin > y_cos),   # Colorier seulement où sin > cos
                alpha=0.2, 
                color='blue',
                label='sin > cos'
               )

# Titre et labels
ax.set_title("Fonctions trigonométriques", fontsize=16, fontweight='bold', pad=15)
ax.set_xlabel("x (radians)", fontsize=13)
ax.set_ylabel("Valeur", fontsize=13)

# Personnalisation des axes
ax.set_xlim(0, 2 * np.pi)           # Limites de l'axe X
ax.set_ylim(-1.3, 1.3)              # Limites de l'axe Y
ax.set_xticks([0, np.pi/2, np.pi, 3*np.pi/2, 2*np.pi])
ax.set_xticklabels(['0', 'π/2', 'π', '3π/2', '2π'], fontsize=11)

# Grille
ax.grid(True, alpha=0.4, linestyle=':')
ax.axhline(0, color='black', linewidth=0.8)   # Ligne horizontale à y=0
ax.axvline(np.pi, color='gray', linewidth=0.8, linestyle='--', label='x=π')

# Annotation flèche
ax.annotate('Maximum sin', 
            xy=(np.pi/2, 1),        # Point à annoter
            xytext=(np.pi/2 + 0.8, 1.1),   # Position du texte
            fontsize=10,
            arrowprops=dict(arrowstyle='->', color='black', lw=1.5),
            ha='left'
           )

# Légende
ax.legend(loc='upper right', fontsize=11, framealpha=0.9)

# Texte dans le graphique
ax.text(0.02, 0.05, "Données: numpy.linspace", 
        transform=ax.transAxes,   # Coordonnées relatives (0-1)
        fontsize=8, color='gray', fontstyle='italic')

# Bordure des spines (côtés du graphique)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

plt.tight_layout()
plt.savefig("matplotlib_base.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 2 : SUBPLOTS — MISE EN PAGE AVANCÉE
# ─────────────────────────────────────────────────────────

# Grille régulière : plt.subplots(nrows, ncols)
fig, axes = plt.subplots(2, 3, figsize=(15, 8),
                          sharex=False,   # Partager l'axe X entre toutes les sous-figures
                          sharey=False)   # Partager l'axe Y

fig.suptitle("Galerie de types de graphiques", fontsize=15, fontweight='bold')

np.random.seed(42)
data = np.random.randn(200)
categories = ['A', 'B', 'C', 'D']
valeurs = [23, 45, 12, 67]
x_scatter = np.random.randn(100)
y_scatter = x_scatter * 0.7 + np.random.randn(100) * 0.5

# Histogramme
axes[0][0].hist(data, bins=25, color='steelblue', edgecolor='white', alpha=0.8)
axes[0][0].set_title("Histogramme", fontweight='bold')
axes[0][0].set_xlabel("Valeur")
axes[0][0].set_ylabel("Fréquence")

# Barres
bars = axes[0][1].bar(categories, valeurs, 
                       color=['#2196F3', '#FF5722', '#4CAF50', '#9C27B0'],
                       edgecolor='black', linewidth=0.5)
# Ajouter les valeurs au-dessus des barres
for bar, val in zip(bars, valeurs):
    axes[0][1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
                    str(val), ha='center', va='bottom', fontweight='bold')
axes[0][1].set_title("Graphique à barres", fontweight='bold')
axes[0][1].set_ylabel("Valeur")

# Scatter plot
scatter = axes[0][2].scatter(x_scatter, y_scatter,
                              c=np.abs(x_scatter),   # Couleur selon la valeur absolue
                              cmap='viridis',
                              alpha=0.7, s=30)
plt.colorbar(scatter, ax=axes[0][2], label="|x|")
axes[0][2].set_title("Scatter Plot", fontweight='bold')
axes[0][2].set_xlabel("x")
axes[0][2].set_ylabel("y")

# Camembert (pie chart)
sizes = [35, 25, 20, 20]
labels = ['Alpha', 'Beta', 'Gamma', 'Delta']
explode = (0.05, 0, 0, 0)   # Mettre en avant la 1ère part
axes[1][0].pie(sizes, labels=labels, explode=explode,
               autopct='%1.1f%%',     # Afficher le pourcentage
               startangle=90,         # Rotation initiale
               colors=['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4'])
axes[1][0].set_title("Camembert", fontweight='bold')

# Boxplot
data_boxplot = [np.random.normal(0, std, 100) for std in [1, 2, 1.5, 0.8]]
bp = axes[1][1].boxplot(data_boxplot, 
                         labels=['σ=1', 'σ=2', 'σ=1.5', 'σ=0.8'],
                         patch_artist=True,        # Remplir les boîtes
                         medianprops={'color': 'red', 'linewidth': 2})
colors_bp = ['#FFB347', '#87CEEB', '#90EE90', '#DDA0DD']
for patch, color in zip(bp['boxes'], colors_bp):
    patch.set_facecolor(color)
axes[1][1].set_title("Boxplot", fontweight='bold')

# Ligne temporelle
dates = pd.date_range('2024-01-01', periods=30, freq='D')
valeurs_ts = np.cumsum(np.random.randn(30)) + 100
axes[1][2].plot(dates, valeurs_ts, 'b-', linewidth=2)
axes[1][2].fill_between(dates, valeurs_ts, 100, 
                          where=(valeurs_ts > 100), 
                          alpha=0.3, color='green', label='Hausse')
axes[1][2].fill_between(dates, valeurs_ts, 100, 
                          where=(valeurs_ts <= 100), 
                          alpha=0.3, color='red', label='Baisse')
axes[1][2].set_title("Série temporelle", fontweight='bold')
axes[1][2].tick_params(axis='x', rotation=45)
axes[1][2].legend(fontsize=8)

plt.tight_layout()
plt.savefig("matplotlib_gallery.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 3 : GRIDSPEC — MISE EN PAGE COMPLEXE
# ─────────────────────────────────────────────────────────

# GridSpec permet des subplots de tailles différentes

fig = plt.figure(figsize=(14, 10))
fig.suptitle("Dashboard avec GridSpec", fontsize=15, fontweight='bold')

# Définir la grille : 3 lignes × 3 colonnes
gs = gridspec.GridSpec(3, 3, 
                        figure=fig,
                        hspace=0.4,    # Espace vertical
                        wspace=0.3)   # Espace horizontal

# Grand graphique sur toute la première ligne
ax_main = fig.add_subplot(gs[0, :])   # Ligne 0, toutes les colonnes
x_ts = np.linspace(0, 10, 300)
ax_main.plot(x_ts, np.sin(x_ts) * np.exp(-x_ts * 0.2), linewidth=2, color='steelblue')
ax_main.set_title("Vue principale — Série temporelle complète")
ax_main.set_xlabel("Temps")
ax_main.set_ylabel("Amplitude")

# Graphique gauche (2 lignes, 1 colonne)
ax_left = fig.add_subplot(gs[1:, 0])   # Lignes 1-2, colonne 0
ax_left.hist(np.random.randn(500), bins=20, orientation='horizontal', 
             color='coral', edgecolor='white')
ax_left.set_title("Distribution\n(horizontal)")
ax_left.set_xlabel("Fréquence")
ax_left.set_ylabel("Valeur")

# 4 petits graphiques en grille 2×2
for i, (row, col) in enumerate([(1,1),(1,2),(2,1),(2,2)]):
    ax = fig.add_subplot(gs[row, col])
    data = np.random.randn(50) + i
    ax.scatter(range(50), data, s=10, alpha=0.7, color=f'C{i}')
    ax.set_title(f"Segment {i+1}", fontsize=9)
    ax.tick_params(labelsize=7)

plt.savefig("gridspec_dashboard.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 4 : STYLES ET PERSONNALISATION AVANCÉE
# ─────────────────────────────────────────────────────────

# Styles disponibles
print("Styles disponibles :")
print(plt.style.available[:10])

# Créer un style personnalisé
plt.rcParams.update({
    'figure.facecolor': '#FAFAFA',
    'axes.facecolor': '#F5F5F5',
    'axes.grid': True,
    'grid.alpha': 0.3,
    'grid.color': '#CCCCCC',
    'font.family': 'DejaVu Sans',
    'font.size': 11,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'axes.prop_cycle': plt.cycler('color', 
        ['#2196F3', '#FF5722', '#4CAF50', '#9C27B0', '#FF9800'])
})

# ─────────────────────────────────────────────────────────
# SECTION 5 : COULEURS ET COLORMAPS
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 3, figsize=(15, 8))
fig.suptitle("Colormaps Matplotlib", fontsize=13)

data_heatmap = np.random.rand(10, 10)

colormaps = ['viridis', 'plasma', 'coolwarm', 'RdYlGn', 'Blues', 'magma']

for i, (ax, cmap_name) in enumerate(zip(axes.flatten(), colormaps)):
    im = ax.imshow(data_heatmap, cmap=cmap_name, aspect='auto')
    plt.colorbar(im, ax=ax, shrink=0.8)
    ax.set_title(f"cmap='{cmap_name}'")
    ax.set_xticks([])
    ax.set_yticks([])

plt.tight_layout()
plt.savefig("colormaps.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 6 : GRAPHIQUES SPÉCIAUX
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 3, figsize=(15, 9))

# 1. Graphique en aire empilée (Stacked Area)
np.random.seed(42)
x = range(12)
produits = {
    'Produit A': np.random.randint(20, 50, 12),
    'Produit B': np.random.randint(15, 40, 12),
    'Produit C': np.random.randint(10, 30, 12)
}
axes[0][0].stackplot(x, produits.values(), labels=produits.keys(),
                     colors=['#4CAF50', '#2196F3', '#FF5722'], alpha=0.8)
axes[0][0].legend(loc='upper left', fontsize=8)
axes[0][0].set_title("Aire empilée (Stacked Area)")
axes[0][0].set_xlabel("Mois")
axes[0][0].set_ylabel("Ventes")

# 2. Graphique radar / araignée
categories_radar = ['Vitesse', 'Résistance', 'Intelligence', 'Agilité', 'Force']
N = len(categories_radar)
angles = [n / float(N) * 2 * np.pi for n in range(N)]
angles += angles[:1]   # Fermer le polygone

vals_hero1 = [8, 6, 9, 7, 5]
vals_hero1 += vals_hero1[:1]
vals_hero2 = [5, 9, 4, 6, 10]
vals_hero2 += vals_hero2[:1]

ax_radar = plt.subplot(2, 3, 2, polar=True)   # Axes polaires pour le radar
ax_radar.plot(angles, vals_hero1, 'o-', linewidth=2, label='Hero 1', color='blue')
ax_radar.fill(angles, vals_hero1, alpha=0.25, color='blue')
ax_radar.plot(angles, vals_hero2, 'o-', linewidth=2, label='Hero 2', color='red')
ax_radar.fill(angles, vals_hero2, alpha=0.25, color='red')
ax_radar.set_xticks(angles[:-1])
ax_radar.set_xticklabels(categories_radar, fontsize=9)
ax_radar.set_ylim(0, 10)
ax_radar.legend(loc='upper right', bbox_to_anchor=(0.1, 0.1), fontsize=8)
ax_radar.set_title("Graphique Radar", pad=20)

# 3. Heatmap
data_corr = np.array([
    [1.0, 0.8, -0.3, 0.5],
    [0.8, 1.0, -0.2, 0.4],
    [-0.3, -0.2, 1.0, -0.6],
    [0.5, 0.4, -0.6, 1.0]
])
im = axes[0][2].imshow(data_corr, cmap='coolwarm', vmin=-1, vmax=1)
plt.colorbar(im, ax=axes[0][2])
for i in range(4):
    for j in range(4):
        axes[0][2].text(j, i, f"{data_corr[i,j]:.1f}",
                        ha='center', va='center', fontsize=10,
                        color='white' if abs(data_corr[i,j]) > 0.6 else 'black')
axes[0][2].set_title("Heatmap de corrélation")
axes[0][2].set_xticks(range(4))
axes[0][2].set_yticks(range(4))

# 4. Barres horizontales groupées
pays = ['France', 'Allemagne', 'Espagne', 'Italie']
annee_2022 = [70, 85, 60, 55]
annee_2023 = [75, 82, 68, 60]

y_pos = np.arange(len(pays))
height = 0.35
axes[1][0].barh(y_pos - height/2, annee_2022, height, label='2022', color='steelblue')
axes[1][0].barh(y_pos + height/2, annee_2023, height, label='2023', color='coral')
axes[1][0].set_yticks(y_pos)
axes[1][0].set_yticklabels(pays)
axes[1][0].legend()
axes[1][0].set_title("Barres horizontales groupées")
axes[1][0].set_xlabel("Valeur (M€)")

# 5. Graphique en chandeliers (OHLC) simplifié
n_jours = 20
dates_ohlc = pd.date_range('2024-01-01', periods=n_jours, freq='B')
prix_close = 100 + np.cumsum(np.random.randn(n_jours) * 2)
prix_open = prix_close - np.random.randn(n_jours)
prix_high = np.maximum(prix_open, prix_close) + np.abs(np.random.randn(n_jours))
prix_low  = np.minimum(prix_open, prix_close) - np.abs(np.random.randn(n_jours))

for i in range(n_jours):
    color = 'green' if prix_close[i] > prix_open[i] else 'red'
    # Mèche haute-basse
    axes[1][1].plot([i, i], [prix_low[i], prix_high[i]], color=color, linewidth=1)
    # Corps
    axes[1][1].bar(i, abs(prix_close[i] - prix_open[i]),
                   bottom=min(prix_open[i], prix_close[i]),
                   color=color, width=0.7, alpha=0.8)
axes[1][1].set_title("Graphique en chandeliers (OHLC)")
axes[1][1].set_ylabel("Prix ($)")
axes[1][1].set_xlabel("Jour")

# 6. Graphique bulles (Bubble chart)
np.random.seed(10)
x_b = np.random.randn(30)
y_b = np.random.randn(30)
sizes_b = np.random.randint(50, 500, 30)   # Taille des bulles
colors_b = np.random.rand(30)

sc = axes[1][2].scatter(x_b, y_b, s=sizes_b, c=colors_b, 
                         cmap='viridis', alpha=0.7, edgecolors='black', linewidth=0.5)
plt.colorbar(sc, ax=axes[1][2], label="Indicateur")
axes[1][2].set_title("Bubble Chart")
axes[1][2].set_xlabel("Variable X")
axes[1][2].set_ylabel("Variable Y")

plt.tight_layout()
plt.savefig("graphiques_speciaux.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 7 : EXPORT PROFESSIONNEL
# ─────────────────────────────────────────────────────────

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot([1, 2, 3, 4], [1, 4, 2, 3], 'b-o', linewidth=2)
ax.set_title("Graphique pour rapport")

# PNG (pixel — pour web, écran)
plt.savefig("rapport.png",
            dpi=300,              # Résolution (150 web, 300 impression)
            bbox_inches='tight',  # Couper les marges blanches
            facecolor='white',    # Fond blanc (pas transparent)
            format='png')

# PDF (vectoriel — pour documents scientifiques)
plt.savefig("rapport.pdf",
            dpi=300,
            bbox_inches='tight',
            format='pdf')

# SVG (vectoriel — pour le web)
plt.savefig("rapport.svg", format='svg', bbox_inches='tight')

plt.close()   # Libérer la mémoire (important en scripts batch)
print("Graphique exporté en PNG, PDF et SVG")
```

================================================================================
CHAPITRE 31 — SEABORN
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE SEABORN ?

Seaborn est une bibliothèque de visualisation statistique construite AU-DESSUS
de Matplotlib. Elle fournit une interface de haut niveau pour créer des
graphiques statistiquement riches avec moins de code.

SEABORN vs MATPLOTLIB :
  - Matplotlib : contrôle total, plus verbeux
  - Seaborn : moins de code, intègre les statistiques, plus beau par défaut

PHILOSOPHIE SEABORN :
  Seaborn travaille NATIVEMENT avec les DataFrames pandas.
  Au lieu de passer des tableaux NumPy, on passe le nom des colonnes :
  sns.boxplot(data=df, x='colonne_x', y='colonne_y', hue='couleur_par')

FAMILLES DE GRAPHIQUES SEABORN :

1. Distribution (distributional) :
   histplot, kdeplot, ecdfplot, rugplot, displot

2. Relations (relational) :
   scatterplot, lineplot, relplot

3. Statistiques catégorielles (categorical) :
   boxplot, violinplot, boxenplot, stripplot, swarmplot, barplot, pointplot

4. Régressions (regression) :
   regplot, lmplot

5. Matrices :
   heatmap, clustermap, pairplot

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 31 — SEABORN MAÎTRISE COMPLÈTE
# ============================================================

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

# Configuration Seaborn
sns.set_theme(
    style='whitegrid',    # 'white', 'dark', 'darkgrid', 'ticks'
    palette='deep',       # Palette de couleurs par défaut
    font_scale=1.1        # Taille des polices
)

# Datasets Seaborn intégrés
tips    = sns.load_dataset('tips')
flights = sns.load_dataset('flights')
titanic = sns.load_dataset('titanic')
iris    = sns.load_dataset('iris')
penguins = sns.load_dataset('penguins')

# ─────────────────────────────────────────────────────────
# SECTION 1 : GRAPHIQUES DE DISTRIBUTION
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 3, figsize=(16, 10))
fig.suptitle("Seaborn — Graphiques de Distribution", fontsize=14)

# histplot (histogramme amélioré)
sns.histplot(
    data=tips,
    x='total_bill',     # Variable à distribuer
    hue='sex',          # Colorer par sexe
    bins=25,            # Nombre de barres
    kde=True,           # Superposer le KDE
    alpha=0.6,          # Transparence
    ax=axes[0][0]
)
axes[0][0].set_title("histplot + KDE par sexe")

# kdeplot (densité uniquement)
sns.kdeplot(
    data=tips,
    x='total_bill',
    hue='time',          # Lunch vs Dinner
    fill=True,           # Remplir sous la courbe
    alpha=0.4,
    common_norm=False,   # KDE indépendante par groupe
    ax=axes[0][1]
)
axes[0][1].set_title("kdeplot par moment")

# ecdfplot (cumulative distribution function)
sns.ecdfplot(
    data=tips,
    x='tip',
    hue='day',
    ax=axes[0][2]
)
axes[0][2].set_title("ECDF (distribution cumulée) par jour")

# displot (figure-level, créé automatiquement la figure)
# displot ne prend pas d'ax -> on le traite séparément
plt.subplot_adjust = None

# Rugplot (marqueurs sur l'axe)
sns.kdeplot(data=tips, x='tip', ax=axes[1][0], color='blue')
sns.rugplot(data=tips, x='tip', ax=axes[1][0], color='red', alpha=0.5)
axes[1][0].set_title("KDE + Rugplot")
axes[1][0].set_xlabel("Tip ($)")

# Bivariée
sns.kdeplot(
    data=penguins.dropna(),
    x='flipper_length_mm',
    y='body_mass_g',
    fill=True,
    cmap='Blues',
    ax=axes[1][1]
)
axes[1][1].set_title("KDE 2D bivarié")

# Histogramme 2D
axes[1][2].hist2d(tips['total_bill'], tips['tip'], bins=20, cmap='YlOrRd')
axes[1][2].set_xlabel("Total Bill")
axes[1][2].set_ylabel("Tip")
axes[1][2].set_title("hist2d (heatmap)")

plt.tight_layout()
plt.savefig("seaborn_distributions.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 2 : GRAPHIQUES CATÉGORIELS
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 3, figsize=(16, 10))
fig.suptitle("Seaborn — Graphiques Catégoriels", fontsize=14)

# boxplot
sns.boxplot(
    data=tips,
    x='day',
    y='total_bill',
    hue='sex',           # Séparer par sexe
    palette='Set2',
    width=0.6,           # Largeur des boîtes
    flierprops={'marker': 'o', 'markersize': 3},  # Style des outliers
    ax=axes[0][0]
)
axes[0][0].set_title("Boxplot par jour et sexe")

# violinplot
sns.violinplot(
    data=tips,
    x='day',
    y='total_bill',
    hue='sex',
    split=True,          # Moitié gauche = Male, droite = Female
    inner='quart',       # Afficher les quartiles à l'intérieur
    palette={'Male': '#4FC3F7', 'Female': '#FF8A80'},
    ax=axes[0][1]
)
axes[0][1].set_title("Violinplot splitté par sexe")

# boxenplot (letter-value plot — meilleur pour grands datasets)
sns.boxenplot(
    data=tips,
    x='day',
    y='total_bill',
    palette='pastel',
    ax=axes[0][2]
)
axes[0][2].set_title("Boxenplot (letter-value)")

# stripplot (chaque point individuel)
sns.stripplot(
    data=tips,
    x='day',
    y='tip',
    hue='smoker',
    jitter=True,         # Décaler légèrement pour éviter la superposition
    alpha=0.7,
    size=4,
    ax=axes[1][0]
)
axes[1][0].set_title("Stripplot (points individuels)")

# swarmplot (points sans superposition)
sns.swarmplot(
    data=iris.sample(100, random_state=42),
    x='species',
    y='sepal_length',
    palette='husl',
    size=4,
    ax=axes[1][1]
)
axes[1][1].set_title("Swarmplot (sans superposition)")

# barplot (moyenne + IC 95%)
sns.barplot(
    data=tips,
    x='day',
    y='tip',
    hue='sex',
    palette='muted',
    capsize=0.1,         # Taille des capuchons d'erreur
    err_kws={'linewidth': 1.5},  # Style des barres d'erreur
    ax=axes[1][2]
)
axes[1][2].set_title("Barplot (moyenne ± IC 95%)")
axes[1][2].set_ylabel("Tip moyen ($)")

plt.tight_layout()
plt.savefig("seaborn_categorical.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 3 : RÉGRESSIONS
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle("Seaborn — Régressions", fontsize=14)

# regplot : scatter + régression simple
sns.regplot(
    data=tips,
    x='total_bill',
    y='tip',
    scatter_kws={'alpha': 0.5, 's': 20},   # Style du scatter
    line_kws={'color': 'red', 'linewidth': 2},  # Style de la ligne
    ci=95,               # Intervalle de confiance à 95%
    ax=axes[0]
)
axes[0].set_title("Régression linéaire\navec IC 95%")

# Régression polynomiale
sns.regplot(
    data=tips,
    x='total_bill',
    y='tip',
    order=2,             # Degré du polynôme
    scatter_kws={'alpha': 0.3, 's': 10},
    line_kws={'color': 'purple'},
    ci=95,
    ax=axes[1]
)
axes[1].set_title("Régression polynomiale\n(ordre 2)")

# Régression logistique (pour variable binaire)
titanic_clean = titanic[['total_bill' if False else 'fare', 'survived']].dropna()
sns.regplot(
    data=titanic.dropna(subset=['fare']).sample(300, random_state=42),
    x='fare',
    y='survived',
    logistic=True,       # Régression logistique
    y_jitter=0.03,       # Jitter vertical pour voir les 0 et 1
    scatter_kws={'alpha': 0.3, 's': 10},
    line_kws={'color': 'darkgreen'},
    ax=axes[2]
)
axes[2].set_title("Régression logistique\n(survived ~ fare)")
axes[2].set_ylabel("Probabilité de survie")

plt.tight_layout()
plt.savefig("seaborn_regression.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 4 : HEATMAPS ET MATRICES
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Heatmap de données pivotées (ex: passagers par mois/année)
flights_pivot = flights.pivot(index='month', columns='year', values='passengers')

sns.heatmap(
    flights_pivot,
    annot=True,          # Afficher les valeurs dans les cellules
    fmt='d',             # Format entier (pas de décimales)
    cmap='YlOrRd',       # Palette
    linewidths=0.5,      # Bordures entre cellules
    linecolor='white',
    cbar_kws={'label': 'Passagers'},  # Label de la barre de couleur
    ax=axes[0]
)
axes[0].set_title("Heatmap — Passagers aériens\n(mois × année)")
axes[0].set_xlabel("Année")
axes[0].set_ylabel("Mois")

# Clustermap (heatmap + dendrogrammes)
# Regroupe les lignes et colonnes similaires
from scipy.cluster.hierarchy import linkage
import warnings
warnings.filterwarnings('ignore')

g = sns.clustermap(
    flights_pivot,
    cmap='YlOrRd',
    figsize=(10, 8),
    method='ward',       # Méthode de clustering
    standard_scale=1,    # Normaliser les colonnes [0,1]
    annot=False
)
g.fig.suptitle("Clustermap — Regroupement automatique", y=1.02)
plt.savefig("seaborn_clustermap.png", dpi=150, bbox_inches='tight')
plt.show()

plt.savefig("seaborn_heatmap.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 5 : FIGURE-LEVEL vs AXES-LEVEL
# ─────────────────────────────────────────────────────────

# AXES-LEVEL : s'intègre dans une Figure existante (prend ax=)
# FIG-LEVEL : crée sa propre Figure, sous-figures automatiques

# FigureGrid — relplot (relationships)
g = sns.relplot(
    data=penguins.dropna(),
    x='flipper_length_mm',
    y='body_mass_g',
    hue='species',       # Couleur par espèce
    style='sex',         # Marqueur par sexe
    size='bill_length_mm',  # Taille selon longueur du bec
    sizes=(30, 200),     # Plage de tailles
    col='island',        # Un subplot par île
    height=4,
    aspect=0.9,
    alpha=0.8
)
g.set_titles("Île : {col_name}")
g.set_axis_labels("Longueur Nageoire (mm)", "Masse (g)")
g.fig.suptitle("Penguins — Relation nageoire vs masse par île", y=1.05)
plt.savefig("seaborn_relplot.png", dpi=150, bbox_inches='tight')
plt.show()

# FacetGrid — catplot (categorical)
g = sns.catplot(
    data=tips,
    x='day',
    y='total_bill',
    col='time',          # Un subplot par moment (Lunch/Dinner)
    hue='sex',
    kind='box',          # Type de graphique catégoriel
    height=5,
    aspect=0.8,
    palette='Set2'
)
g.set_titles("{col_name}")
g.set_axis_labels("Jour", "Total ($)")
g.fig.suptitle("Total par jour et moment", y=1.02)
plt.savefig("seaborn_catplot.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# SECTION 6 : PAIRPLOT (PAIR GRID)
# ─────────────────────────────────────────────────────────

g = sns.pairplot(
    penguins.dropna(),
    hue='species',
    diag_kind='kde',     # KDE sur la diagonale (distribution)
    plot_kws={'alpha': 0.5, 's': 15},
    diag_kws={'alpha': 0.6},
    corner=True          # N'afficher que le triangle inférieur
)
g.fig.suptitle("Pairplot — Penguins par espèce", y=1.02)
plt.savefig("seaborn_pairplot.png", dpi=100, bbox_inches='tight')
plt.show()
```

================================================================================
CHAPITRE 32 — GRAPHIQUES AVANCÉS ET INTERACTIFS (PLOTLY)
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

QU'EST-CE QUE PLOTLY ?

Plotly est une bibliothèque de visualisation INTERACTIVE pour Python.
Contrairement à Matplotlib/Seaborn qui produisent des images statiques,
Plotly génère des graphiques HTML interactifs :
  - Zoom avec la souris
  - Survol (hover) pour voir les valeurs
  - Filtrage par clic sur la légende
  - Export PNG depuis le graphique

QUAND UTILISER PLOTLY ?
  [OK] Dashboards web (Dash, Streamlit)
  [OK] Présentation de résultats à des non-techniciens
  [OK] Exploration interactive de données
  [OK] Graphiques 3D
  
QUAND PRÉFÉRER MATPLOTLIB/SEABORN ?
  [OK] Publications scientifiques (PDF haute résolution)
  [OK] Rapports PDF/LaTeX
  [OK] Automatisation de graphiques en batch
  [OK] Environnements sans navigateur web

DEUX INTERFACES PLOTLY :
  1. plotly.express (px) : Interface simple, haut niveau (comme Seaborn)
  2. plotly.graph_objects (go) : Contrôle total (comme Matplotlib OO)

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRE 32 — PLOTLY ET VISUALISATIONS AVANCÉES
# ============================================================

# pip install plotly
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
import numpy as np

# ─────────────────────────────────────────────────────────
# SECTION 1 : PLOTLY EXPRESS — INTERFACE SIMPLE
# ─────────────────────────────────────────────────────────

# Charger les données
import seaborn as sns
tips    = sns.load_dataset('tips')
diamonds = sns.load_dataset('diamonds').sample(2000, random_state=42)
gapminder = px.data.gapminder()   # Dataset mondial de développement

# Scatter plot interactif
fig = px.scatter(
    tips,
    x='total_bill',
    y='tip',
    color='sex',          # Couleur par sexe
    size='size',          # Taille bulle par groupe
    symbol='smoker',      # Forme par fumeur
    hover_data=['day', 'time'],  # Données supplémentaires au survol
    trendline='ols',      # Ligne de tendance (OLS = moindres carrés)
    title="Pourboires vs Note totale — Dataset Tips",
    labels={
        'total_bill': 'Note totale ($)',
        'tip': 'Pourboire ($)',
        'sex': 'Sexe'
    },
    template='plotly_white'   # Thème
)

fig.update_layout(
    width=800, height=500,
    title_font_size=16,
    legend_title_text='Sexe'
)

# Pour afficher dans Jupyter : fig.show()
# Pour sauvegarder en HTML :
fig.write_html("scatter_interactif.html")
# Pour sauvegarder en image :
# fig.write_image("scatter.png")   # Nécessite kaleido : pip install kaleido

print("Scatter interactif créé : scatter_interactif.html")

# ─────────────────────────────────────────────────────────
# SECTION 2 : ANIMATION (GAPMINDER)
# ─────────────────────────────────────────────────────────

# Animation par année (célèbre visualisation de Hans Rosling)
fig_anim = px.scatter(
    gapminder,
    x='gdpPercap',         # PIB par habitant
    y='lifeExp',           # Espérance de vie
    size='pop',            # Taille = population
    color='continent',
    hover_name='country',  # Nom du pays au survol
    animation_frame='year',  # Animer par année
    animation_group='country',  # Suivre chaque pays
    log_x=True,            # Axe X logarithmique
    size_max=55,
    range_x=[100, 100000],
    range_y=[25, 90],
    title="Développement mondial 1952-2007 (style Rosling)"
)

fig_anim.update_layout(height=600)
fig_anim.write_html("gapminder_animation.html")
print("Animation créée : gapminder_animation.html")

# ─────────────────────────────────────────────────────────
# SECTION 3 : GRAPHIQUES PLOTLY GO AVANCÉS
# ─────────────────────────────────────────────────────────

# Dashboard multi-graphiques
fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=['Distribution Prix', 'Prix par Cut', 'Carat vs Prix', 'Heatmap Corrélation'],
    specs=[[{'type': 'histogram'}, {'type': 'box'}],
           [{'type': 'scatter'}, {'type': 'heatmap'}]]
)

# 1. Histogramme
fig.add_trace(
    go.Histogram(x=diamonds['price'], nbinsx=50, name='Prix',
                 marker_color='steelblue', opacity=0.8),
    row=1, col=1
)

# 2. Boxplot par cut
cuts = diamonds['cut'].unique()
colors_cut = px.colors.qualitative.Set2
for i, cut in enumerate(cuts):
    fig.add_trace(
        go.Box(y=diamonds[diamonds['cut'] == cut]['price'],
               name=cut, marker_color=colors_cut[i % len(colors_cut)]),
        row=1, col=2
    )

# 3. Scatter carat vs prix
fig.add_trace(
    go.Scatter(
        x=diamonds['carat'],
        y=diamonds['price'],
        mode='markers',
        name='Diamants',
        marker=dict(
            color=diamonds['price'],
            colorscale='Viridis',
            size=4,
            opacity=0.6,
            colorbar=dict(title='Prix $', x=1.02)
        )
    ),
    row=2, col=1
)

# 4. Heatmap de corrélation
cols_num = ['carat', 'depth', 'table', 'price', 'x', 'y', 'z']
corr_matrix = diamonds[cols_num].corr()

fig.add_trace(
    go.Heatmap(
        z=corr_matrix.values,
        x=corr_matrix.columns.tolist(),
        y=corr_matrix.index.tolist(),
        colorscale='RdBu',
        zmid=0,
        text=corr_matrix.round(2).values,
        texttemplate="%{text}",
        hoverongaps=False
    ),
    row=2, col=2
)

fig.update_layout(
    height=800,
    title_text="Dashboard Diamonds — Plotly",
    showlegend=False,
    template='plotly_white'
)

fig.write_html("dashboard_diamonds.html")
print("Dashboard créé : dashboard_diamonds.html")

# ─────────────────────────────────────────────────────────
# SECTION 4 : GRAPHIQUES SPÉCIAUX PLOTLY
# ─────────────────────────────────────────────────────────

# TREEMAP : représentation hiérarchique par superficie
fig_tree = px.treemap(
    diamonds,
    path=['cut', 'color'],   # Hiérarchie : cut -> color
    values='price',          # Taille des cases = prix moyen
    color='price',
    color_continuous_scale='RdYlGn',
    title="Treemap — Prix moyen par cut et couleur"
)
fig_tree.write_html("treemap.html")

# SUNBURST : graphique soleil (hiérarchie circulaire)
fig_sun = px.sunburst(
    diamonds.sample(500),
    path=['cut', 'color', 'clarity'],
    values='price',
    title="Sunburst — Hiérarchie cut -> color -> clarity"
)
fig_sun.write_html("sunburst.html")

# GRAPHIQUE 3D
fig_3d = px.scatter_3d(
    diamonds.sample(500),
    x='carat',
    y='depth',
    z='price',
    color='cut',
    opacity=0.7,
    title="Scatter 3D — Carat, Depth, Prix"
)
fig_3d.write_html("scatter3d.html")

# SANKEY (flux entre catégories)
fig_sankey = go.Figure(go.Sankey(
    node=dict(
        pad=15,
        thickness=20,
        line=dict(color='black', width=0.5),
        label=['Brut', 'Nettoyé', 'Transformé', 'Analysé', 'Visualisé', 'Insights'],
        color=['#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#F44336', '#00BCD4']
    ),
    link=dict(
        source=[0, 1, 2, 3, 4],
        target=[1, 2, 3, 4, 5],
        value=[1000, 950, 930, 920, 900],
        label=['Nettoyage', 'Transformation', 'Analyse', 'Visualisation', 'Insights'],
        color=['rgba(33,150,243,0.4)', 'rgba(76,175,80,0.4)', 
               'rgba(255,152,0,0.4)', 'rgba(156,39,176,0.4)', 'rgba(244,67,54,0.4)']
    )
))
fig_sankey.update_layout(title_text="Pipeline Data Science (Diagramme Sankey)")
fig_sankey.write_html("sankey.html")

print("Graphiques spéciaux créés : treemap, sunburst, 3D, sankey")

# ─────────────────────────────────────────────────────────
# SECTION 5 : INTÉGRATION MATPLOTLIB + PLOTLY (HYBRIDE)
# ─────────────────────────────────────────────────────────

# Convertir une figure Matplotlib en Plotly
import matplotlib
matplotlib.use('Agg')  # Backend non-interactif

import matplotlib.pyplot as plt
import plotly.tools as tls

fig_mpl, ax_mpl = plt.subplots()
ax_mpl.plot([1, 2, 3], [4, 2, 5], 'b-o')
ax_mpl.set_title("Figure Matplotlib convertie en Plotly")

# Conversion (nécessite plotly)
try:
    fig_plotly = tls.mpl_to_plotly(fig_mpl)
    fig_plotly.write_html("matplotlib_to_plotly.html")
    print("Conversion Matplotlib -> Plotly réussie")
except:
    print("Note: mpl_to_plotly peut avoir des limitations")

plt.close()

# ─────────────────────────────────────────────────────────
# SECTION 6 : CHOIX DE LA BONNE VISUALISATION
# ─────────────────────────────────────────────────────────

print("""
╔══════════════════════════════════════════════════════════════════╗
║  GUIDE DE CHOIX DES VISUALISATIONS                              ║
╚══════════════════════════════════════════════════════════════════╝

[OBJECTIF] OBJECTIF -> TYPE DE GRAPHIQUE RECOMMANDÉ

DISTRIBUTION :
  Une variable numérique -> histogramme, KDE, boxplot
  Deux variables numériques -> scatter, hexbin, KDE 2D
  Variable catégorielle -> bar chart de fréquence, pie (< 5 catégories)

COMPARAISON :
  Une var. num. entre groupes -> boxplot, violinplot, barplot (IC)
  Plusieurs métriques -> radar chart, heatmap, parallel coordinates
  Avant/Après -> barres groupées, lignes avec marqueurs

CORRÉLATION :
  2 variables numériques -> scatter, regplot
  Plusieurs variables -> pairplot, heatmap de corrélation
  Corrélation temporelle -> cross-correlation plot

COMPOSITION :
  Parts d'un tout -> pie (< 5), donut, stacked bar
  Évolution de la composition -> stacked area, 100% bar
  Hiérarchie -> treemap, sunburst

ÉVOLUTION TEMPORELLE :
  Série unique -> line chart
  Plusieurs séries -> multi-line, area
  Changements brusques -> step plot
  Distribution dans le temps -> boxplot / violin par période

GÉOGRAPHIQUE :
  Par région -> choropleth map (px.choropleth)
  Points géographiques -> scatter_mapbox, scatter_geo
  Densité -> density_mapbox

[ATTENTION] RÈGLES D'OR :
  1. Pie chart -> seulement si < 5-6 catégories
  2. Ne jamais couper l'axe Y à 0 si c'est trompeur
  3. Étiquettes > légende (quand possible)
  4. Pas de 3D sauf pour 3 variables VRAIMENT
  5. Colorblind-friendly : éviter rouge/vert ensemble
     Utiliser : viridis, plasma, cividis, tableau10
""")
```

────────────────────────────────────────────────────────────────────────────────
9. EXERCICES PRATIQUES — PARTIE 7
────────────────────────────────────────────────────────────────────────────────

── NIVEAU FACILE ──

Ex 30.1 : Créez une figure Matplotlib avec 4 subplots (2×2) montrant :
  - Histogramme de la colonne 'age' du dataset Titanic
  - Boxplot de 'fare' par 'pclass'
  - Scatter de 'age' vs 'fare'
  - Bar chart du taux de survie par 'sex'

Ex 31.1 : Avec Seaborn, créez un violinplot de 'sepal_length' du dataset Iris
  par espèce. Ajoutez split=False et inner='box'.

Ex 32.1 : Avec Plotly Express, créez un scatter interactif du dataset Gapminder
  pour l'année 2007. Colorez par continent, taille = population.

── NIVEAU INTERMÉDIAIRE ──

Ex 30.2 : Recréez la célèbre visualisation de Florence Nightingale
  (Coxcomb / Rose Chart) pour montrer les causes de décès en guerre.
  Données : catégories ['Maladie', 'Combat', 'Autre'],
            valeurs = [83, 12, 5]
  Utiliser un graphique polaire.

Ex 31.2 : Créez un FacetGrid Seaborn sur le dataset Tips :
  - Lignes : 'time' (Lunch, Dinner)
  - Colonnes : 'smoker' (Yes, No)
  - Pour chaque case : histogramme de 'tip'

Ex 32.2 : Créez un dashboard Plotly avec 4 graphiques sur les données Diamonds :
  Sunburst, Scatter 3D, Heatmap de corrélation, Histogram.

── NIVEAU AVANCÉ ──

Ex 30.3 : Créez une visualisation de la courbe de Lorenz (inégalité des revenus).
  La courbe de Lorenz trace le % cumulé des revenus vs le % cumulé de la population.
  La diagonale = parfaite égalité. L'aire entre la courbe et la diagonale = coefficient Gini.
  Calculez et affichez le coefficient Gini.

Ex 32.3 : Construisez un mini-dashboard Plotly Dash (pip install dash)
  avec des filtres interactifs (dropdown pour choisir le pays, slider pour l'année)
  et deux graphiques mis à jour en temps réel.

================================================================================
     RÉSUMÉ PARTIE 7 — VISUALISATION
================================================================================

CHAPITRE 30 — MATPLOTLIB :
  [OK] Architecture : Figure -> Axes -> Artists
  [OK] Interface OO vs pyplot
  [OK] Types : ligne, barre, scatter, pie, boxplot, heatmap
  [OK] Subplots et GridSpec
  [OK] Personnalisation : couleurs, styles, annotations
  [OK] Export : PNG 300dpi, PDF vectoriel, SVG

CHAPITRE 31 — SEABORN :
  [OK] Familles : distribution, catégoriel, relation, régression, matrice
  [OK] histplot, kdeplot, violinplot, boxplot, barplot
  [OK] heatmap, clustermap, pairplot
  [OK] Figure-level (relplot, catplot) vs Axes-level
  [OK] hue, col, row pour facettes automatiques

CHAPITRE 32 — PLOTLY :
  [OK] px (express) vs go (graph objects)
  [OK] Interactivité : zoom, hover, filtres
  [OK] Animations (gapminder)
  [OK] Graphiques spéciaux : treemap, sunburst, 3D, sankey
  [OK] Export HTML pour dashboards

================================================================================
FIN PARTIE 7
================================================================================

================================================================================
     GUIDE COMPLET DATA SCIENCE AVEC PYTHON — PARTIE 8
     STATISTIQUES, MACHINE LEARNING ET PROJET COMPLET
     Chapitres 33 à 41
================================================================================

"Le machine learning, c'est de la statistique habillée en informatique."
                                                                 — Anonyme

================================================================================
CHAPITRE 33 — TESTS STATISTIQUES : COMPARER DES GROUPES
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. INTRODUCTION PÉDAGOGIQUE
────────────────────────────────────────────────────────────────────────────────

POURQUOI LES TESTS STATISTIQUES ?

Vous observez une différence dans vos données : le groupe A a une moyenne
plus élevée que le groupe B. Mais est-ce RÉEL ou juste dû au hasard ?

C'est précisément la question des tests statistiques :
"Quelle est la probabilité d'observer cette différence par hasard,
si en réalité il n'y avait AUCUNE différence ?"

VOCABULAIRE ESSENTIEL :

H0 (hypothèse nulle)       : "Pas de différence / pas d'effet"
H1 (hypothèse alternative) : "Il y a une différence / un effet"
p-value                    : Probabilité d'observer ces données si H0 est vraie
α (seuil)                  : Seuil de décision, conventionnellement 0.05

RÈGLE :
  p < α  -> On REJETTE H0 (résultat "significatif")
  p ≥ α  -> On NE PEUT PAS rejeter H0 (pas suffisamment de preuves)

[ATTENTION] PIÈGE COURANT : "p ≥ 0.05" ne signifie PAS que H0 est vraie !
   Cela signifie seulement qu'on n'a pas assez de preuves pour la rejeter.

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION PRATIQUE
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# CHAPITRES 33-35 — STATISTIQUES ET TESTS COMPLETS
# ============================================================

import numpy as np
import pandas as pd
import scipy.stats as stats
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)

# ─────────────────────────────────────────────────────────
# ARBRE DE DÉCISION : CHOISIR SON TEST
# ─────────────────────────────────────────────────────────

"""
GUIDE DE SÉLECTION :

Variable dépendante NUMÉRIQUE :
  ├── Comparer 2 groupes indépendants :
  │     Données normales ?  OUI -> t-test (ttest_ind)
  │                         NON -> Mann-Whitney U (mannwhitneyu)
  │
  ├── Comparer 2 groupes appariés (avant/après) :
  │     Données normales ?  OUI -> t-test apparié (ttest_rel)
  │                         NON -> Wilcoxon (wilcoxon)
  │
  └── Comparer 3+ groupes :
        Données normales ?  OUI -> ANOVA (f_oneway) + Tukey post-hoc
                            NON -> Kruskal-Wallis (kruskal) + Dunn

Variable dépendante CATÉGORIELLE :
  └── Association entre 2 catégorielles -> Chi-carré (chi2_contingency)
  └── Comparer des proportions          -> z-test proportions

Corrélation entre 2 numériques :
  Linéaire  -> Pearson  (pearsonr)
  Monotone  -> Spearman (spearmanr)
"""

# ─────────────────────────────────────────────────────────
# TEST DE NORMALITÉ — SHAPIRO-WILK
# ─────────────────────────────────────────────────────────

def tester_normalite(data, nom="Variable", alpha=0.05):
    """Test Shapiro-Wilk de normalité avec interprétation."""
    sample = data[:500] if len(data) > 500 else data
    stat, p = stats.shapiro(sample)
    normale = p >= alpha
    print(f"  Shapiro-Wilk ({nom}) : W={stat:.4f}, p={p:.4f} -> "
          f"{'[OK] NORMALE' if normale else '[X] NON NORMALE'}")
    return normale

# ─────────────────────────────────────────────────────────
# T-TEST DE STUDENT : COMPARER 2 MOYENNES
# ─────────────────────────────────────────────────────────

# Contexte : comparer les notes de deux méthodes pédagogiques
notes_A = np.random.normal(72, 12, 50)   # Méthode classique
notes_B = np.random.normal(78, 11, 48)   # Méthode active

print("T-TEST DE STUDENT")
print("─" * 50)
print(f"Groupe A : n={len(notes_A)}, μ={notes_A.mean():.2f}, σ={notes_A.std():.2f}")
print(f"Groupe B : n={len(notes_B)}, μ={notes_B.mean():.2f}, σ={notes_B.std():.2f}")

# Vérifier la normalité
tester_normalite(notes_A, "Groupe A")
tester_normalite(notes_B, "Groupe B")

# Test de Levene (égalité des variances)
lev_stat, lev_p = stats.levene(notes_A, notes_B)
variances_egales = lev_p >= 0.05
print(f"\n  Levene (variances égales ?) : p={lev_p:.4f} -> "
      f"{'égales' if variances_egales else 'différentes'}")

# T-test (Welch si variances différentes, Student sinon)
t_stat, p_val = stats.ttest_ind(notes_A, notes_B, equal_var=variances_egales)

# Taille d'effet : Cohen's d
pooled_std = np.sqrt(((len(notes_A)-1)*notes_A.std()**2 +
                      (len(notes_B)-1)*notes_B.std()**2) /
                     (len(notes_A) + len(notes_B) - 2))
cohens_d = (notes_B.mean() - notes_A.mean()) / pooled_std

print(f"\n  t-statistique : {t_stat:.4f}")
print(f"  p-value       : {p_val:.4f}")
print(f"  Cohen's d     : {cohens_d:.3f} "
      f"({'négligeable' if abs(cohens_d)<0.2 else 'petit' if abs(cohens_d)<0.5 else 'moyen' if abs(cohens_d)<0.8 else 'grand'})")

if p_val < 0.05:
    print(f"\n[OK] RÉSULTAT SIGNIFICATIF (p={p_val:.4f} < 0.05)")
    print(f"   La méthode B améliore significativement les notes.")
else:
    print(f"\n[X] RÉSULTAT NON SIGNIFICATIF (p={p_val:.4f} ≥ 0.05)")

# ─────────────────────────────────────────────────────────
# MANN-WHITNEY U : ALTERNATIVE NON PARAMÉTRIQUE
# ─────────────────────────────────────────────────────────

# Données asymétriques (revenus) -> non normales -> Mann-Whitney
revenus_A = np.random.lognormal(10.5, 0.4, 80)
revenus_B = np.random.lognormal(10.7, 0.4, 80)

print("\nMANN-WHITNEY U (non paramétrique)")
print("─" * 50)
tester_normalite(revenus_A, "Revenus A")

u_stat, p_mw = stats.mannwhitneyu(revenus_A, revenus_B, alternative='two-sided')
print(f"\n  U-statistic : {u_stat:.2f}")
print(f"  p-value     : {p_mw:.4f}")
print(f"  Médiane A   : {np.median(revenus_A):.0f} €")
print(f"  Médiane B   : {np.median(revenus_B):.0f} €")
print(f"  Conclusion  : {'Différence significative' if p_mw < 0.05 else 'Pas de différence'}")

# ─────────────────────────────────────────────────────────
# ANOVA : COMPARER 3+ GROUPES
# ─────────────────────────────────────────────────────────

print("\nANOVA — 4 MÉTHODES PÉDAGOGIQUES")
print("─" * 50)

m1 = np.random.normal(68, 10, 30)
m2 = np.random.normal(74, 10, 30)
m3 = np.random.normal(71, 10, 30)
m4 = np.random.normal(80, 10, 30)

f_stat, p_anova = stats.f_oneway(m1, m2, m3, m4)
print(f"F-statistic : {f_stat:.4f}")
print(f"p-value     : {p_anova:.4f}")

if p_anova < 0.05:
    print("[OK] ANOVA significatif — au moins une méthode diffère")

    # Post-hoc Tukey HSD : identifier QUELS groupes diffèrent
    from scipy.stats import tukey_hsd
    result = tukey_hsd(m1, m2, m3, m4)
    noms = ['M1(68)', 'M2(74)', 'M3(71)', 'M4(80)']
    print("\nTest post-hoc Tukey HSD :")
    for i in range(4):
        for j in range(i+1, 4):
            p_pair = result.pvalue[i, j]
            sig = "[OK] DIFFÉRENTS" if p_pair < 0.05 else "  non-sig"
            print(f"  {noms[i]} vs {noms[j]} : p={p_pair:.4f}  {sig}")

# ─────────────────────────────────────────────────────────
# CHI-CARRÉ : ASSOCIATION ENTRE CATÉGORIELLES
# ─────────────────────────────────────────────────────────

print("\nTEST CHI-CARRÉ — Sexe vs Survie (Titanic)")
print("─" * 50)

titanic = sns.load_dataset('titanic')
table = pd.crosstab(titanic['sex'], titanic['survived'],
                    margins=True, margins_name='Total')
print(table)

chi2, p_chi2, dof, expected = stats.chi2_contingency(
    pd.crosstab(titanic['sex'], titanic['survived']))
n = len(titanic)
cramers_v = np.sqrt(chi2 / (n * (min(2,2) - 1)))

print(f"\nChi²={chi2:.2f}, dof={dof}, p={p_chi2:.6f}")
print(f"Cramer's V={cramers_v:.3f} (force de l'association)")
print("[OK] Association très significative entre sexe et survie" if p_chi2 < 0.05 else "Pas d'association")

# ─────────────────────────────────────────────────────────
# INTERVALLE DE CONFIANCE
# ─────────────────────────────────────────────────────────

def intervalle_confiance_moyenne(data, confiance=0.95):
    """IC de la moyenne via distribution t de Student."""
    n = len(data)
    moy = np.mean(data)
    s   = np.std(data, ddof=1)
    t_crit = stats.t.ppf((1 + confiance) / 2, df=n - 1)
    marge = t_crit * s / np.sqrt(n)
    print(f"\nIC {confiance*100:.0f}% de la moyenne : "
          f"[{moy-marge:.2f}, {moy+marge:.2f}]  (μ̂={moy:.2f} ± {marge:.2f})")
    return moy - marge, moy + marge

intervalle_confiance_moyenne(notes_B)

# ─────────────────────────────────────────────────────────
# A/B TEST COMPLET
# ─────────────────────────────────────────────────────────

print("\n" + "=" * 65)
print("A/B TEST — TAUX DE CONVERSION SITE WEB")
print("=" * 65)

n_A, n_B = 2000, 2000
taux_A, taux_B = 0.12, 0.145

conv_A = np.random.binomial(1, taux_A, n_A)
conv_B = np.random.binomial(1, taux_B, n_B)

ca, cb = conv_A.sum(), conv_B.sum()
pa, pb = ca / n_A, cb / n_B

print(f"Page A : {ca}/{n_A} = {pa:.2%}")
print(f"Page B : {cb}/{n_B} = {pb:.2%}")
print(f"Uplift : +{(pb-pa)/pa:.1%} relatif")

# Test z des proportions
from statsmodels.stats.proportion import proportions_ztest
z, p_ab = proportions_ztest([ca, cb], [n_A, n_B], alternative='two-sided')
print(f"\nz={z:.3f}, p={p_ab:.5f}")
print("[OK] Amélioration significative -> déployer B" if p_ab < 0.05
      else "[X] Pas de preuve -> continuer le test")
```

================================================================================
CHAPITRE 36 — INTRODUCTION AU MACHINE LEARNING
================================================================================

────────────────────────────────────────────────────────────────────────────────
1. CONCEPTS FONDAMENTAUX
────────────────────────────────────────────────────────────────────────────────

LES 3 FAMILLES DE ML :

1. SUPERVISÉ : données + labels fournis
   - Régression : prédire une valeur continue (prix, température, CA)
   - Classification : prédire une catégorie (spam/ham, maladie/sain)

2. NON SUPERVISÉ : données sans labels
   - Clustering : K-Means, DBSCAN, Hierarchical
   - Réduction de dimension : PCA, t-SNE, UMAP

3. PAR RENFORCEMENT : apprentissage par récompense
   - AlphaGo, robots, systèmes de recommandation

VOCABULAIRE SKLEARN :
  X          : Matrice des features (n_samples × n_features)
  y          : Vecteur cible (n_samples,)
  fit()      : Apprendre sur les données d'entraînement
  predict()  : Faire des prédictions
  score()    : Évaluer le modèle (R² ou accuracy selon le contexte)
  transform(): Transformer les données (scalers, encoders)

RÈGLES D'OR :
  1. Jamais utiliser le set de test pour entraîner ou tuner
  2. Normaliser AVANT d'entraîner (sauf arbres)
  3. Cross-valider pour une évaluation fiable
  4. Commencer simple, complexifier si nécessaire

================================================================================
CHAPITRE 37 — RÉGRESSION
================================================================================

```python
# ============================================================
# CHAPITRE 37 — RÉGRESSION COMPLÈTE
# ============================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)

# ─────────────────────────────────────────────────────────
# DATASET IMMOBILIER
# ─────────────────────────────────────────────────────────

n = 600
surface  = np.random.normal(100, 35, n).clip(20, 350)
pieces   = np.random.randint(1, 8, n).astype(float)
age_bien = np.random.randint(0, 60, n).astype(float)
distance = np.random.exponential(6, n).clip(0.5, 40)
parking  = np.random.choice([0, 1], n, p=[0.35, 0.65])
quartier = np.random.choice(['Premium', 'Standard', 'Populaire'], n,
                             p=[0.2, 0.5, 0.3])

prix = (surface * 3200
        + pieces * 12000
        - age_bien * 600
        - distance * 4500
        + parking * 18000
        + (quartier == 'Premium').astype(int) * 80000
        - (quartier == 'Populaire').astype(int) * 30000
        + np.random.normal(0, 22000, n))

df = pd.DataFrame({
    'surface': surface, 'pieces': pieces, 'age': age_bien,
    'distance_km': distance, 'parking': parking,
    'quartier': quartier, 'prix': prix
})

# Encodage one-hot
df_enc = pd.get_dummies(df, columns=['quartier'], drop_first=True)

X = df_enc.drop(columns=['prix'])
y = df_enc['prix']

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42)

print(f"Train : {X_train.shape[0]} | Test : {X_test.shape[0]}")

# ─────────────────────────────────────────────────────────
# FONCTION D'ÉVALUATION COMMUNE
# ─────────────────────────────────────────────────────────

def evaluer_regression(nom, y_reel, y_pred, verbose=True):
    """Calcule et affiche les métriques de régression."""
    r2   = r2_score(y_reel, y_pred)
    mae  = mean_absolute_error(y_reel, y_pred)
    rmse = np.sqrt(mean_squared_error(y_reel, y_pred))
    mape = np.mean(np.abs((y_reel - y_pred) / y_reel)) * 100

    if verbose:
        print(f"\n  {nom}")
        print(f"    R²   = {r2:.4f}  (1=parfait, 0=nul, <0=pire que moyenne)")
        print(f"    MAE  = {mae:>10,.0f} € (erreur absolue moyenne)")
        print(f"    RMSE = {rmse:>10,.0f} € (racine erreur quadratique)")
        print(f"    MAPE = {mape:>10.2f} %  (erreur % absolue moyenne)")
    return {'nom': nom, 'R2': r2, 'MAE': mae, 'RMSE': rmse, 'MAPE': mape}

# ─────────────────────────────────────────────────────────
# MODÈLE 1 : RÉGRESSION LINÉAIRE
# ─────────────────────────────────────────────────────────

pipe_lr = Pipeline([
    ('scaler', StandardScaler()),
    ('model',  LinearRegression())
])
pipe_lr.fit(X_train, y_train)
y_pred_lr = pipe_lr.predict(X_test)
res_lr = evaluer_regression("Régression Linéaire", y_test, y_pred_lr)

# Coefficients interprétables
coefs = pd.Series(pipe_lr['model'].coef_, index=X.columns)
print("\n  Coefficients :")
for feat, c in coefs.sort_values(key=abs, ascending=False).items():
    print(f"    {feat:30s} : {c:+,.0f} € / unité")

# ─────────────────────────────────────────────────────────
# MODÈLE 2 : RIDGE (L2 REGULARIZATION)
# ─────────────────────────────────────────────────────────

# Ridge pénalise les grands coefficients -> réduit le sur-apprentissage
# α (alpha) contrôle la force de régularisation
# α=0 -> LinearRegression, α->∞ -> tous coefficients à 0

pipe_ridge = Pipeline([
    ('scaler', StandardScaler()),
    ('model',  Ridge(alpha=100))
])
pipe_ridge.fit(X_train, y_train)
res_ridge = evaluer_regression("Ridge (α=100)", y_test, pipe_ridge.predict(X_test))

# ─────────────────────────────────────────────────────────
# MODÈLE 3 : LASSO (L1 REGULARIZATION + SÉLECTION DE FEATURES)
# ─────────────────────────────────────────────────────────

# Lasso peut réduire certains coefficients à EXACTEMENT 0
# -> Sélection automatique de features !

pipe_lasso = Pipeline([
    ('scaler', StandardScaler()),
    ('model',  Lasso(alpha=500, max_iter=10000))
])
pipe_lasso.fit(X_train, y_train)
res_lasso = evaluer_regression("Lasso (α=500)", y_test, pipe_lasso.predict(X_test))

coefs_lasso = pd.Series(pipe_lasso['model'].coef_, index=X.columns)
features_actives = coefs_lasso[coefs_lasso != 0]
features_nulles  = coefs_lasso[coefs_lasso == 0]
print(f"  Features actives  ({len(features_actives)}) : {features_actives.index.tolist()}")
print(f"  Features éliminées({len(features_nulles)}) : {features_nulles.index.tolist()}")

# ─────────────────────────────────────────────────────────
# MODÈLE 4 : RANDOM FOREST REGRESSOR
# ─────────────────────────────────────────────────────────

pipe_rf = Pipeline([
    ('model', RandomForestRegressor(
        n_estimators=200,
        max_depth=12,
        min_samples_leaf=5,
        max_features=0.7,
        random_state=42,
        n_jobs=-1
    ))
])
pipe_rf.fit(X_train, y_train)
res_rf = evaluer_regression("Random Forest", y_test, pipe_rf.predict(X_test))

# Feature importance
imp = pd.Series(pipe_rf['model'].feature_importances_, index=X.columns)
imp_sorted = imp.sort_values(ascending=True)
plt.figure(figsize=(8, 4))
imp_sorted.plot(kind='barh', color='steelblue', edgecolor='white')
plt.title("Importance des features — Random Forest")
plt.xlabel("Importance")
plt.tight_layout()
plt.savefig("rf_feature_importance.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# MODÈLE 5 : GRADIENT BOOSTING
# ─────────────────────────────────────────────────────────

pipe_gb = Pipeline([
    ('scaler', StandardScaler()),
    ('model', GradientBoostingRegressor(
        n_estimators=200,
        learning_rate=0.05,   # Taux d'apprentissage
        max_depth=4,
        subsample=0.8,        # Sous-échantillonnage des données
        random_state=42
    ))
])
pipe_gb.fit(X_train, y_train)
res_gb = evaluer_regression("Gradient Boosting", y_test, pipe_gb.predict(X_test))

# ─────────────────────────────────────────────────────────
# COMPARAISON FINALE ET VISUALISATION
# ─────────────────────────────────────────────────────────

resultats = pd.DataFrame([res_lr, res_ridge, res_lasso, res_rf, res_gb])
resultats = resultats.set_index('nom').round(4)
print("\n\n=== TABLEAU COMPARATIF DES MODÈLES ===")
print(resultats.to_string())

# Graphiques prédit vs réel
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for i, (nom, pipe) in enumerate([("Régression Linéaire", pipe_lr),
                                   ("Random Forest", pipe_rf)]):
    y_p = pipe.predict(X_test)
    ax = axes[i]
    ax.scatter(y_test, y_p, alpha=0.4, s=15, color=f'C{i}')
    lim = [min(y_test.min(), y_p.min()), max(y_test.max(), y_p.max())]
    ax.plot(lim, lim, 'r--', linewidth=2, label='Parfait')
    ax.set_title(f"{nom}\nR²={r2_score(y_test, y_p):.3f}")
    ax.set_xlabel("Prix Réel (€)")
    ax.set_ylabel("Prix Prédit (€)")

plt.tight_layout()
plt.savefig("regression_predits_vs_reels.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# HYPERPARAMETER TUNING : GRIDSEARCHCV
# ─────────────────────────────────────────────────────────

# Trouver les meilleurs hyperparamètres par validation croisée
print("\nGrid Search — Random Forest ...")
param_grid = {
    'model__n_estimators': [100, 200],
    'model__max_depth':    [8, 12, None],
    'model__min_samples_leaf': [3, 5]
}

gs = GridSearchCV(
    Pipeline([('model', RandomForestRegressor(random_state=42, n_jobs=-1))]),
    param_grid,
    cv=5,
    scoring='r2',
    n_jobs=-1,
    verbose=0
)
gs.fit(X_train, y_train)

print(f"  Meilleurs params : {gs.best_params_}")
print(f"  Meilleur R² (CV) : {gs.best_score_:.4f}")
print(f"  R² sur test      : {r2_score(y_test, gs.predict(X_test)):.4f}")

# ─────────────────────────────────────────────────────────
# COURBES D'APPRENTISSAGE (DIAGNOSTIC BIAIS/VARIANCE)
# ─────────────────────────────────────────────────────────

from sklearn.model_selection import learning_curve

train_sizes, train_scores, val_scores = learning_curve(
    pipe_rf, X, y,
    train_sizes=np.linspace(0.1, 1.0, 8),
    cv=5, scoring='r2', n_jobs=-1
)

plt.figure(figsize=(9, 5))
plt.plot(train_sizes, train_scores.mean(axis=1), 'o-',
         label='Score Train', color='blue')
plt.fill_between(train_sizes,
                 train_scores.mean(axis=1) - train_scores.std(axis=1),
                 train_scores.mean(axis=1) + train_scores.std(axis=1),
                 alpha=0.15, color='blue')
plt.plot(train_sizes, val_scores.mean(axis=1), 'o-',
         label='Score Validation', color='red')
plt.fill_between(train_sizes,
                 val_scores.mean(axis=1) - val_scores.std(axis=1),
                 val_scores.mean(axis=1) + val_scores.std(axis=1),
                 alpha=0.15, color='red')
plt.xlabel("Taille de l'ensemble d'entraînement")
plt.ylabel("R²")
plt.title("Courbe d'apprentissage — Random Forest")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("learning_curve.png", dpi=150, bbox_inches='tight')
plt.show()
# Si train >> val -> sur-apprentissage (high variance)
# Si train ≈ val ≈ faible -> sous-apprentissage (high bias)
```

================================================================================
CHAPITRE 38 — CLASSIFICATION
================================================================================

```python
# ============================================================
# CHAPITRE 38 — CLASSIFICATION COMPLÈTE
# ============================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import (train_test_split, cross_val_score,
                                      StratifiedKFold)
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.metrics import (accuracy_score, classification_report,
                              confusion_matrix, roc_curve, auc,
                              precision_recall_curve, f1_score)
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)

# ─────────────────────────────────────────────────────────
# DATASET : CANCER DU SEIN (BINARY CLASSIFICATION)
# ─────────────────────────────────────────────────────────

cancer = load_breast_cancer()
X = pd.DataFrame(cancer.data, columns=cancer.feature_names)
y = cancer.target   # 0=malin, 1=bénin

print(f"Dataset Cancer du Sein")
print(f"Shape : {X.shape}")
print(f"Classe 0 (malin)  : {(y==0).sum()} ({(y==0).mean():.1%})")
print(f"Classe 1 (bénin)  : {(y==1).sum()} ({(y==1).mean():.1%})")

# Split avec stratification (préserve les proportions de classes)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# ─────────────────────────────────────────────────────────
# MÉTRIQUES DE CLASSIFICATION — EXPLICATION
# ─────────────────────────────────────────────────────────

"""
Matrice de confusion :
                 Prédit 0    Prédit 1
  Réel 0  │  VP (TN)  │  FP (FP)  │   -> Spécificité = TN / (TN + FP)
  Réel 1  │  FN (FN)  │  TP (TP)  │   -> Sensibilité = TP / (TP + FN)

Accuracy  = (TP + TN) / Total          -> % de bonnes prédictions
Precision = TP / (TP + FP)             -> Parmi les "Oui" prédits, combien sont vrais ?
Recall    = TP / (TP + FN)             -> Parmi les vrais "Oui", combien détectés ?
F1-Score  = 2 × (Precision × Recall) / (Precision + Recall)

QUAND UTILISER QUELLE MÉTRIQUE ?
  - Accuracy  : classes équilibrées, coûts symétriques
  - Precision : faux positifs coûteux (ex: spam -> annonce légitime perdue)
  - Recall    : faux négatifs coûteux (ex: cancer -> maladie non détectée !)
  - F1-Score  : compromis precision/recall (classes déséquilibrées)
  - AUC-ROC   : évaluation globale, indépendante du seuil
"""

def evaluer_classification(nom, y_reel, y_pred, y_prob=None):
    """Rapport complet de classification."""
    acc  = accuracy_score(y_reel, y_pred)
    f1   = f1_score(y_reel, y_pred, average='weighted')

    print(f"\n{'─'*50}")
    print(f"{nom}")
    print(f"{'─'*50}")
    print(f"  Accuracy  : {acc:.4f}")
    print(f"  F1-Score  : {f1:.4f}")
    print(f"\n{classification_report(y_reel, y_pred, target_names=['Malin','Bénin'])}")

    roc_auc = None
    if y_prob is not None:
        fpr, tpr, _ = roc_curve(y_reel, y_prob)
        roc_auc = auc(fpr, tpr)
        print(f"  AUC-ROC   : {roc_auc:.4f}")

    return {'nom': nom, 'Accuracy': acc, 'F1': f1, 'AUC': roc_auc}

resultats = []

# ─────────────────────────────────────────────────────────
# MODÈLE 1 : RÉGRESSION LOGISTIQUE
# ─────────────────────────────────────────────────────────

pipe_logreg = Pipeline([
    ('scaler', StandardScaler()),
    ('model',  LogisticRegression(C=1.0, max_iter=1000, random_state=42))
])
pipe_logreg.fit(X_train, y_train)
y_pred_lr  = pipe_logreg.predict(X_test)
y_prob_lr  = pipe_logreg.predict_proba(X_test)[:, 1]
res = evaluer_classification("Régression Logistique", y_test, y_pred_lr, y_prob_lr)
resultats.append(res)

# ─────────────────────────────────────────────────────────
# MODÈLE 2 : RANDOM FOREST CLASSIFIER
# ─────────────────────────────────────────────────────────

pipe_rf = Pipeline([
    ('model', RandomForestClassifier(
        n_estimators=200, max_depth=10,
        min_samples_leaf=3, random_state=42, n_jobs=-1
    ))
])
pipe_rf.fit(X_train, y_train)
y_pred_rf = pipe_rf.predict(X_test)
y_prob_rf = pipe_rf.predict_proba(X_test)[:, 1]
res = evaluer_classification("Random Forest", y_test, y_pred_rf, y_prob_rf)
resultats.append(res)

# ─────────────────────────────────────────────────────────
# MODÈLE 3 : GRADIENT BOOSTING
# ─────────────────────────────────────────────────────────

pipe_gb = Pipeline([
    ('scaler', StandardScaler()),
    ('model', GradientBoostingClassifier(
        n_estimators=200, learning_rate=0.05,
        max_depth=3, random_state=42
    ))
])
pipe_gb.fit(X_train, y_train)
y_pred_gb = pipe_gb.predict(X_test)
y_prob_gb = pipe_gb.predict_proba(X_test)[:, 1]
res = evaluer_classification("Gradient Boosting", y_test, y_pred_gb, y_prob_gb)
resultats.append(res)

# ─────────────────────────────────────────────────────────
# MODÈLE 4 : KNN
# ─────────────────────────────────────────────────────────

pipe_knn = Pipeline([
    ('scaler', StandardScaler()),
    ('model',  KNeighborsClassifier(n_neighbors=7))
])
pipe_knn.fit(X_train, y_train)
y_pred_knn = pipe_knn.predict(X_test)
y_prob_knn = pipe_knn.predict_proba(X_test)[:, 1]
res = evaluer_classification("KNN (k=7)", y_test, y_pred_knn, y_prob_knn)
resultats.append(res)

# ─────────────────────────────────────────────────────────
# TABLEAU COMPARATIF
# ─────────────────────────────────────────────────────────

df_res = pd.DataFrame(resultats).set_index('nom').round(4)
print("\n=== TABLEAU COMPARATIF DES MODÈLES ===")
print(df_res.to_string())

# ─────────────────────────────────────────────────────────
# VISUALISATIONS
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# 1. Matrices de confusion
modeles_plot = [
    ("Logistique", y_pred_lr),
    ("Random Forest", y_pred_rf),
    ("Gradient Boosting", y_pred_gb)
]
for i, (nom, y_p) in enumerate(modeles_plot):
    cm = confusion_matrix(y_test, y_p)
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
                xticklabels=['Malin','Bénin'],
                yticklabels=['Malin','Bénin'],
                ax=axes[i])
    axes[i].set_title(f"Confusion — {nom}")
    axes[i].set_xlabel("Prédit")
    axes[i].set_ylabel("Réel")

plt.tight_layout()
plt.savefig("confusion_matrices.png", dpi=150, bbox_inches='tight')
plt.show()

# 2. Courbes ROC
plt.figure(figsize=(8, 6))
for nom, y_prob in [("Logistique", y_prob_lr), ("RF", y_prob_rf),
                     ("GBM", y_prob_gb), ("KNN", y_prob_knn)]:
    fpr, tpr, _ = roc_curve(y_test, y_prob)
    plt.plot(fpr, tpr, label=f"{nom} (AUC={auc(fpr,tpr):.3f})", linewidth=2)

plt.plot([0,1], [0,1], 'k--', alpha=0.4, label='Aléatoire')
plt.xlabel("Taux de Faux Positifs (1-Spécificité)")
plt.ylabel("Taux de Vrais Positifs (Sensibilité)")
plt.title("Courbes ROC — Comparaison des modèles")
plt.legend(loc='lower right')
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("roc_curves.png", dpi=150, bbox_inches='tight')
plt.show()

# 3. Courbe Precision-Recall
plt.figure(figsize=(8, 5))
for nom, y_prob in [("Logistique", y_prob_lr), ("RF", y_prob_rf), ("GBM", y_prob_gb)]:
    prec, rec, _ = precision_recall_curve(y_test, y_prob)
    plt.plot(rec, prec, label=nom, linewidth=2)

plt.xlabel("Recall")
plt.ylabel("Precision")
plt.title("Courbe Precision-Recall")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("precision_recall_curve.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# AJUSTEMENT DU SEUIL DE DÉCISION
# ─────────────────────────────────────────────────────────

# Par défaut, sklearn utilise seuil = 0.5
# En médecine, on préfère détecter tous les cas (rappel élevé)
# -> abaisser le seuil augmente le recall mais diminue la précision

seuils = np.linspace(0.1, 0.9, 17)
recalls, precisions, f1s = [], [], []

for seuil in seuils:
    y_pred_seuil = (y_prob_gb >= seuil).astype(int)
    recalls.append(   float(f1_score(y_test, y_pred_seuil, pos_label=1,
                                      average=None)[0]) )  # recall classe 1
    precisions.append( float(f1_score(y_test, y_pred_seuil, pos_label=1,
                                       average=None)[0]) )
    f1s.append(f1_score(y_test, y_pred_seuil, average='weighted'))

plt.figure(figsize=(9, 4))
plt.plot(seuils, f1s, 'g-o', label='F1 pondéré', linewidth=2)
plt.axvline(0.5, color='gray', linestyle='--', label='Seuil par défaut')
best_seuil = seuils[np.argmax(f1s)]
plt.axvline(best_seuil, color='red', linestyle='--',
            label=f'Meilleur seuil={best_seuil:.2f}')
plt.xlabel("Seuil de décision")
plt.ylabel("Score")
plt.title("Impact du seuil sur le F1-Score (GBM)")
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("threshold_tuning.png", dpi=150, bbox_inches='tight')
plt.show()
print(f"Meilleur seuil : {best_seuil:.2f} (F1={max(f1s):.4f})")

# ─────────────────────────────────────────────────────────
# CROSS-VALIDATION STRATIFIÉE
# ─────────────────────────────────────────────────────────

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

print("\nCross-validation stratifiée (5-fold) :")
for nom, pipe in [("Logistique", pipe_logreg), ("RF", pipe_rf),
                   ("GBM", pipe_gb)]:
    scores = cross_val_score(pipe, X, y, cv=skf, scoring='roc_auc', n_jobs=-1)
    print(f"  {nom:25s} AUC: {scores.mean():.4f} ± {scores.std():.4f}")
```

================================================================================
CHAPITRES 39-41 — PROJET DATA COMPLET
================================================================================

────────────────────────────────────────────────────────────────────────────────
CONTEXTE DU PROJET
────────────────────────────────────────────────────────────────────────────────

MISSION : Vous êtes data scientist chez une compagnie d'assurance santé.
          On vous demande de prédire les charges d'assurance médicale
          d'un client en fonction de son profil.

DATASET : Medical Insurance (adapté du dataset Kaggle)
COLONNES :
  - age          : âge du client
  - sex          : sexe (male/female)
  - bmi          : indice de masse corporelle
  - children     : nombre d'enfants couverts
  - smoker       : fumeur (yes/no)
  - region       : région (northeast/northwest/southeast/southwest)
  - charges      : charges médicales annuelles (CIBLE)

LIVRABLES ATTENDUS :
  1. Rapport EDA (exploration et insights)
  2. Modèle prédictif avec R² > 0.80
  3. Features les plus importantes
  4. Rapport final en format professionnel

────────────────────────────────────────────────────────────────────────────────
6. IMPLÉMENTATION COMPLÈTE DU PROJET
────────────────────────────────────────────────────────────────────────────────

```python
# ============================================================
# PROJET COMPLET — PRÉDICTION CHARGES D'ASSURANCE
# ============================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.metrics import r2_score, mean_absolute_error, mean_squared_error
import warnings
warnings.filterwarnings('ignore')
np.random.seed(42)

print("╔══════════════════════════════════════════════════════╗")
print("║    PROJET : PRÉDICTION CHARGES MÉDICALES             ║")
print("╚══════════════════════════════════════════════════════╝")

# ─────────────────────────────────────────────────────────
# ÉTAPE 1 : GÉNÉRATION DU DATASET (SIMULATION RÉALISTE)
# ─────────────────────────────────────────────────────────

n = 1338
age      = np.random.randint(18, 65, n)
sex      = np.random.choice(['male', 'female'], n)
bmi      = np.random.normal(30.5, 6, n).clip(15, 55)
children = np.random.poisson(1.1, n).clip(0, 5)
smoker   = np.random.choice(['yes', 'no'], n, p=[0.20, 0.80])
region   = np.random.choice(['northeast','northwest','southeast','southwest'], n)

# Formule de charges réaliste
charges = (
    age * 250
    + (bmi - 25) * 100
    + children * 500
    + (smoker == 'yes').astype(int) * 15000
    + (region == 'southeast').astype(int) * 1200
    - (sex == 'female').astype(int) * 800
    + np.random.lognormal(6.5, 0.5, n)
).clip(1000)

df = pd.DataFrame({
    'age': age, 'sex': sex, 'bmi': bmi,
    'children': children, 'smoker': smoker,
    'region': region, 'charges': charges
})

# Introduire quelques NaN réalistes
for col in ['bmi', 'age']:
    mask = np.random.random(n) < 0.02
    df.loc[mask, col] = np.nan

print(f"\n[1] Dataset créé : {df.shape}")
print(df.head())
print(df.describe())

# ─────────────────────────────────────────────────────────
# ÉTAPE 2 : EDA — EXPLORATION
# ─────────────────────────────────────────────────────────

print("\n[2] ANALYSE EXPLORATOIRE")
print("─" * 50)
print(f"NaN : {df.isnull().sum().to_dict()}")
print(f"Doublons : {df.duplicated().sum()}")

# Distribution de la cible
plt.figure(figsize=(14, 4))

plt.subplot(1, 3, 1)
df['charges'].hist(bins=40, color='steelblue', edgecolor='white')
plt.title(f"Distribution Charges\nSkew={df['charges'].skew():.2f}")
plt.xlabel("Charges ($)")

plt.subplot(1, 3, 2)
np.log(df['charges']).hist(bins=40, color='green', edgecolor='white')
plt.title(f"Log(Charges)\nSkew={np.log(df['charges']).skew():.2f}")
plt.xlabel("log(Charges)")

plt.subplot(1, 3, 3)
df.boxplot(column='charges', by='smoker', figsize=None)
plt.title("Charges par statut fumeur")
plt.xlabel("Fumeur")
plt.ylabel("Charges ($)")
plt.suptitle("")

plt.tight_layout()
plt.savefig("projet_eda_distribution.png", dpi=150, bbox_inches='tight')
plt.show()

# Insights clés
print("\nINSIGHTS :")
fumeurs    = df[df['smoker']=='yes']['charges']
non_fumeurs = df[df['smoker']=='no']['charges']
print(f"  Charges moy. fumeurs    : {fumeurs.mean():>10,.0f} $")
print(f"  Charges moy. non-fumeurs: {non_fumeurs.mean():>10,.0f} $")
print(f"  Ratio fumeur/non-fumeur : {fumeurs.mean()/non_fumeurs.mean():.1f}×")
print(f"  Corrélation age/charges : {df['age'].corr(df['charges']):.3f}")
print(f"  Corrélation bmi/charges : {df['bmi'].corr(df['charges']):.3f}")

# Heatmap
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

corr = df.select_dtypes(include=[np.number]).corr()
sns.heatmap(corr, annot=True, fmt='.2f', cmap='coolwarm',
            center=0, ax=axes[0])
axes[0].set_title("Corrélation entre variables numériques")

sns.scatterplot(data=df, x='age', y='charges',
                hue='smoker', alpha=0.5, s=20, ax=axes[1])
axes[1].set_title("Âge vs Charges (coloré par fumeur)")

plt.tight_layout()
plt.savefig("projet_eda_correlations.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# ÉTAPE 3 : FEATURE ENGINEERING
# ─────────────────────────────────────────────────────────

print("\n[3] FEATURE ENGINEERING")
print("─" * 50)

df_fe = df.copy()

# Interaction: fumeur × bmi (les fumeurs obèses coûtent beaucoup plus)
df_fe['smoker_bmi'] = (df_fe['smoker'] == 'yes').astype(int) * df_fe['bmi'].fillna(df_fe['bmi'].median())

# BMI catégoriel
df_fe['bmi_cat'] = pd.cut(df_fe['bmi'],
                           bins=[0, 18.5, 25, 30, 100],
                           labels=['Sous-poids', 'Normal', 'Surpoids', 'Obèse'])

# Âge catégoriel
df_fe['age_cat'] = pd.cut(df_fe['age'],
                            bins=[17, 30, 45, 65],
                            labels=['Jeune', 'Adulte', 'Senior'])

# Charge par enfant
df_fe['charges_par_enfant'] = np.where(
    df_fe['children'] > 0,
    df_fe['charges'] / df_fe['children'],
    df_fe['charges']
)

print(f"Features ajoutées : smoker_bmi, bmi_cat, age_cat, charges_par_enfant")
print(df_fe[['smoker_bmi', 'bmi_cat', 'age_cat']].head())

# ─────────────────────────────────────────────────────────
# ÉTAPE 4 : PRÉPARATION POUR LE ML
# ─────────────────────────────────────────────────────────

print("\n[4] PRÉPARATION ML")

features = ['age', 'sex', 'bmi', 'children', 'smoker', 'region', 'smoker_bmi']
X = df_fe[features].copy()
y = df_fe['charges']

cols_num = ['age', 'bmi', 'children', 'smoker_bmi']
cols_cat = ['sex', 'smoker', 'region']

# Pipeline de preprocessing
num_pipeline = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler',  StandardScaler())
])
cat_pipeline = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('encoder', OneHotEncoder(drop='first', sparse_output=False, handle_unknown='ignore'))
])

preprocessor = ColumnTransformer([
    ('num', num_pipeline, cols_num),
    ('cat', cat_pipeline, cols_cat)
])

# Split
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(f"Train : {X_train.shape[0]} | Test : {X_test.shape[0]}")

# ─────────────────────────────────────────────────────────
# ÉTAPE 5 : ENTRAÎNEMENT ET COMPARAISON DES MODÈLES
# ─────────────────────────────────────────────────────────

print("\n[5] MODÈLES")
print("─" * 50)

modeles = {
    'Linéaire':         Pipeline([('prep', preprocessor), ('model', LinearRegression())]),
    'Ridge':            Pipeline([('prep', preprocessor), ('model', Ridge(alpha=100))]),
    'Random Forest':    Pipeline([('prep', preprocessor),
                                   ('model', RandomForestRegressor(
                                       n_estimators=200, max_depth=12,
                                       random_state=42, n_jobs=-1))]),
    'Gradient Boosting':Pipeline([('prep', preprocessor),
                                   ('model', GradientBoostingRegressor(
                                       n_estimators=200, learning_rate=0.05,
                                       max_depth=4, random_state=42))])
}

resultats_modeles = []
for nom, pipeline in modeles.items():
    pipeline.fit(X_train, y_train)
    y_pred = pipeline.predict(X_test)
    r2   = r2_score(y_test, y_pred)
    mae  = mean_absolute_error(y_test, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    cv   = cross_val_score(pipeline, X, y, cv=5, scoring='r2', n_jobs=-1)
    print(f"  {nom:20s} R²={r2:.3f}  MAE=${mae:>7,.0f}  RMSE=${rmse:>7,.0f}  "
          f"CV={cv.mean():.3f}±{cv.std():.3f}")
    resultats_modeles.append({'Modèle': nom, 'R²': r2, 'MAE': mae, 'RMSE': rmse,
                               'CV_mean': cv.mean(), 'CV_std': cv.std()})

# ─────────────────────────────────────────────────────────
# ÉTAPE 6 : MEILLEUR MODÈLE — OPTIMISATION
# ─────────────────────────────────────────────────────────

print("\n[6] OPTIMISATION — GRADIENT BOOSTING")

param_grid = {
    'model__n_estimators': [100, 200, 300],
    'model__learning_rate': [0.03, 0.05, 0.1],
    'model__max_depth': [3, 4, 5]
}

gb_pipeline = Pipeline([
    ('prep', preprocessor),
    ('model', GradientBoostingRegressor(random_state=42))
])

gs = GridSearchCV(gb_pipeline, param_grid, cv=5, scoring='r2',
                   n_jobs=-1, verbose=0)
gs.fit(X_train, y_train)
meilleur_modele = gs.best_estimator_

y_pred_final = meilleur_modele.predict(X_test)
r2_final     = r2_score(y_test, y_pred_final)
mae_final    = mean_absolute_error(y_test, y_pred_final)
rmse_final   = np.sqrt(mean_squared_error(y_test, y_pred_final))

print(f"  Meilleurs params : {gs.best_params_}")
print(f"  R²   = {r2_final:.4f}")
print(f"  MAE  = {mae_final:,.0f} $")
print(f"  RMSE = {rmse_final:,.0f} $")

# ─────────────────────────────────────────────────────────
# ÉTAPE 7 : INTERPRÉTABILITÉ
# ─────────────────────────────────────────────────────────

print("\n[7] INTERPRÉTABILITÉ — FEATURE IMPORTANCE")

# Récupérer les noms après transformation
cat_cols_encoded = (meilleur_modele.named_steps['prep']
                    .named_transformers_['cat']
                    .named_steps['encoder']
                    .get_feature_names_out(cols_cat))
all_features = cols_num + list(cat_cols_encoded)

importances = meilleur_modele.named_steps['model'].feature_importances_
imp_series = pd.Series(importances, index=all_features).sort_values(ascending=True)

plt.figure(figsize=(9, 5))
imp_series.tail(12).plot(kind='barh', color='steelblue', edgecolor='white')
plt.title("Top Features — Gradient Boosting (Charges médicales)")
plt.xlabel("Importance")
plt.tight_layout()
plt.savefig("projet_feature_importance.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# ÉTAPE 8 : VISUALISATION FINALE
# ─────────────────────────────────────────────────────────

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Rapport Final — Prédiction Charges Médicales", fontsize=14)

# 1. Prédit vs Réel
axes[0][0].scatter(y_test, y_pred_final, alpha=0.4, s=15, color='steelblue')
lim = [y_test.min(), y_test.max()]
axes[0][0].plot(lim, lim, 'r--', linewidth=2)
axes[0][0].set_xlabel("Charges Réelles ($)")
axes[0][0].set_ylabel("Charges Prédites ($)")
axes[0][0].set_title(f"Prédit vs Réel — R²={r2_final:.3f}")

# 2. Distribution des résidus
residus = y_test - y_pred_final
axes[0][1].hist(residus, bins=40, color='coral', edgecolor='white')
axes[0][1].axvline(0, color='black', linewidth=1, linestyle='--')
axes[0][1].set_xlabel("Résidu ($)")
axes[0][1].set_ylabel("Fréquence")
axes[0][1].set_title(f"Distribution des Résidus\n(Bias={residus.mean():.0f}$)")

# 3. Résidus vs Valeurs prédites
axes[1][0].scatter(y_pred_final, residus, alpha=0.4, s=15, color='green')
axes[1][0].axhline(0, color='red', linestyle='--', linewidth=1)
axes[1][0].set_xlabel("Charges Prédites ($)")
axes[1][0].set_ylabel("Résidu ($)")
axes[1][0].set_title("Résidus vs Prédictions")

# 4. Comparaison des modèles
df_res = pd.DataFrame(resultats_modeles)
df_res.set_index('Modèle')['R²'].plot(kind='barh', ax=axes[1][1],
                                       color='purple', edgecolor='white')
axes[1][1].axvline(0.8, color='red', linestyle='--', label='Objectif R²=0.80')
axes[1][1].legend()
axes[1][1].set_xlabel("R²")
axes[1][1].set_title("Comparaison R² des modèles")

plt.tight_layout()
plt.savefig("projet_rapport_final.png", dpi=150, bbox_inches='tight')
plt.show()

# ─────────────────────────────────────────────────────────
# ÉTAPE 9 : RAPPORT FINAL FORMATÉ
# ─────────────────────────────────────────────────────────

print(f"""
╔══════════════════════════════════════════════════════════════════╗
║              RAPPORT FINAL — PRÉDICTION CHARGES MÉDICALES        ║
╠══════════════════════════════════════════════════════════════════╣

[LISTE] CONTEXTE
   Dataset : {n} clients avec {len(features)} features
   Cible   : charges médicales annuelles (en dollars)
   Split   : 80% train / 20% test

[GRAPHIQUE] ANALYSE EXPLORATOIRE — INSIGHTS CLÉS
   • Les fumeurs coûtent {fumeurs.mean()/non_fumeurs.mean():.1f}× plus cher que les non-fumeurs
   • L'âge est corrélé positivement avec les charges (r={df['age'].corr(df['charges']):.2f})
   • Le BMI a une corrélation modérée avec les charges (r={df['bmi'].corr(df['charges']):.2f})
   • La distribution est très asymétrique -> log-transform recommandé

[BOT] MODÈLES TESTÉS
   • Régression Linéaire  : baseline simple
   • Ridge               : linéaire régularisé
   • Random Forest        : ensemble non-linéaire
   • Gradient Boosting    : meilleur modèle [OK]

[TROPHEE] MEILLEUR MODÈLE : Gradient Boosting (optimisé)
   • Paramètres  : {gs.best_params_}
   • R²          : {r2_final:.4f} ({'[OK] Objectif atteint' if r2_final > 0.80 else '[ATTENTION] Objectif non atteint'})
   • MAE         : {mae_final:,.0f} $
   • RMSE        : {rmse_final:,.0f} $

[RECHERCHE] FEATURES LES PLUS IMPORTANTES
   • smoker_bmi : interaction fumeur × BMI (impact le plus fort)
   • smoker     : statut fumeur
   • age        : âge du client
   • bmi        : indice de masse corporelle

[IDEE] RECOMMANDATIONS BUSINESS
   • Prioriser le statut fumeur dans les questionnaires d'assurance
   • Créer des offres ciblées pour les profils à risque (fumeur + obèse + senior)
   • Le modèle permet d'estimer les charges à ±{mae_final:,.0f}$ en moyenne
   • Retrainer le modèle trimestriellement avec les nouvelles données

╚══════════════════════════════════════════════════════════════════╝
""")

# ─────────────────────────────────────────────────────────
# ÉTAPE 10 : MISE EN PRODUCTION — SAUVEGARDER LE MODÈLE
# ─────────────────────────────────────────────────────────

import joblib

# Sauvegarder le modèle entraîné
joblib.dump(meilleur_modele, "modele_assurance.pkl")
print("Modèle sauvegardé : modele_assurance.pkl")

# Recharger et utiliser
modele_charge = joblib.load("modele_assurance.pkl")

# Prédire pour un nouveau client
nouveau_client = pd.DataFrame({
    'age': [42],
    'sex': ['male'],
    'bmi': [29.5],
    'children': [2],
    'smoker': ['yes'],
    'region': ['southeast'],
    'smoker_bmi': [29.5]   # smoker(1) × bmi = 29.5
})

prediction = modele_charge.predict(nouveau_client)[0]
print(f"\nPrédiction pour nouveau client (42 ans, fumeur, BMI=29.5) :")
print(f"  Charges estimées : {prediction:,.0f} $")
```

================================================================================
EXERCICES PRATIQUES — PARTIE 8
================================================================================

── NIVEAU FACILE ──

Ex 33.1 : Sur le dataset Iris, testez si la longueur des sépales est
  significativement différente entre setosa et virginica.
  Utilisez le test t (après avoir vérifié la normalité).

Ex 36.1 : Sur le dataset Titanic, entraînez un modèle de classification
  (LogisticRegression) pour prédire la survie. Affichez l'accuracy et
  le rapport de classification.

── NIVEAU INTERMÉDIAIRE ──

Ex 37.1 : Sur le dataset California Housing (sklearn.datasets.fetch_california_housing),
  comparez LinearRegression, Ridge, et RandomForest.
  Évaluez par cross-validation (cv=5) avec R².
  Quel modèle est le meilleur ?

Ex 38.1 : Sur le dataset Pima Indians Diabetes (disponible sur Kaggle),
  entraînez un GradientBoostingClassifier.
  Tracez la courbe ROC et calculez l'AUC.
  Ajustez le seuil pour maximiser le recall des diabétiques.

── NIVEAU AVANCÉ ──

Ex 39.1 : Projet complet sur le dataset Wine Quality (Kaggle) :
  1. EDA complète avec dashboard
  2. Feature engineering (ratios, interactions)
  3. Classification binaire (qualité ≥ 7 -> "bon vin")
  4. Comparaison de 5 modèles
  5. Optimisation par GridSearch
  6. Rapport final avec métriques et insights

Ex 40.1 : Créez un pipeline de bout en bout reproductible :
  - Lecture du CSV
  - Nettoyage automatique (NaN, doublons, outliers)
  - EDA automatique
  - Entraînement de 3 modèles
  - Sélection automatique du meilleur
  - Sauvegarde du rapport en HTML

================================================================================
CORRIGÉS SÉLECTIONNÉS
================================================================================

```python
# ── CORRIGÉ 33.1 ──
from sklearn.datasets import load_iris
import scipy.stats as stats
import numpy as np

iris = load_iris()
import pandas as pd
df_iris = pd.DataFrame(iris.data, columns=iris.feature_names)
df_iris['species'] = iris.target

setosa    = df_iris[df_iris['species']==0]['sepal length (cm)']
virginica = df_iris[df_iris['species']==2]['sepal length (cm)']

# Normalité
_, p_s = stats.shapiro(setosa)
_, p_v = stats.shapiro(virginica)
print(f"Shapiro setosa: p={p_s:.4f} | virginica: p={p_v:.4f}")

# Variances
_, p_lev = stats.levene(setosa, virginica)
t, p_t = stats.ttest_ind(setosa, virginica, equal_var=(p_lev >= 0.05))

print(f"t={t:.3f}, p={p_t:.6f}")
print("Différence significative [OK]" if p_t < 0.05 else "Pas de différence")
print(f"Setosa μ={setosa.mean():.2f} | Virginica μ={virginica.mean():.2f}")

# ── CORRIGÉ 36.1 ──
import seaborn as sns
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report

titanic = sns.load_dataset('titanic')
features = ['pclass', 'age', 'sibsp', 'parch', 'fare']
titanic_clean = titanic[features + ['survived']].dropna()

X_t = titanic_clean[features]
y_t = titanic_clean['survived']

X_tr, X_te, y_tr, y_te = train_test_split(X_t, y_t, test_size=0.2, random_state=42)

pipe = Pipeline([('scaler', StandardScaler()),
                  ('model', LogisticRegression(max_iter=1000))])
pipe.fit(X_tr, y_tr)
y_p = pipe.predict(X_te)

print(f"Accuracy : {accuracy_score(y_te, y_p):.4f}")
print(classification_report(y_te, y_p, target_names=['Décédé','Survécu']))
```

================================================================================
     RÉSUMÉ PARTIE 8 — STATISTIQUES, ML ET PROJET
================================================================================

CHAPITRES 33-35 — STATISTIQUES :
  [OK] Tests paramétriques : t-test, ANOVA + Tukey
  [OK] Tests non paramétriques : Mann-Whitney, Kruskal-Wallis
  [OK] Chi-carré (association catégorielle)
  [OK] Intervalles de confiance
  [OK] A/B test complet avec puissance statistique

CHAPITRE 36 — INTRO ML :
  [OK] Supervisé vs non supervisé vs renforcement
  [OK] Vocabulaire sklearn : fit/predict/transform
  [OK] Règles d'or (séparation train/test, normalisation)

CHAPITRE 37 — RÉGRESSION :
  [OK] Linéaire, Ridge, Lasso, ElasticNet
  [OK] Random Forest, Gradient Boosting
  [OK] Métriques : R², MAE, RMSE, MAPE
  [OK] Cross-validation, GridSearchCV
  [OK] Courbes d'apprentissage (biais/variance)

CHAPITRE 38 — CLASSIFICATION :
  [OK] Logistique, RF, GBM, KNN
  [OK] Confusion matrix, Accuracy, F1, AUC-ROC
  [OK] Courbe Precision-Recall
  [OK] Ajustement du seuil de décision
  [OK] Cross-validation stratifiée

CHAPITRES 39-41 — PROJET COMPLET :
  [OK] Pipeline de bout en bout
  [OK] Feature engineering métier
  [OK] ColumnTransformer (num + cat)
  [OK] Interprétabilité (feature importance)
  [OK] Rapport professionnel formaté
  [OK] Sérialisation du modèle (joblib)

================================================================================
     RÉSUMÉ GÉNÉRAL DU GUIDE COMPLET (PARTIES 1-8)
================================================================================

PARTIE 1 — INTRODUCTION & PYTHON :
  Données, DIKW, cycle de vie, bases Python, environnement

PARTIE 2 — PYTHON AVANCÉ :
  Structures avancées, fonctionnelles, classes, modules, fichiers

PARTIE 3 — NUMPY :
  Arrays, indexing, vectorisation, broadcasting, linéaire

PARTIE 4 — PANDAS :
  Series/DataFrame, exploration, manipulation, groupby, merge, pivot

PARTIE 5 — NETTOYAGE :
  NaN (MCAR/MAR/MNAR), doublons, outliers (IQR/Z/IsoForest), normalisation

PARTIE 6 — EDA :
  Stats descriptives, distributions, corrélations, insights, dashboard

PARTIE 7 — VISUALISATION :
  Matplotlib (anatomy, subplots), Seaborn (statistical), Plotly (interactif)

PARTIE 8 — STATISTIQUES & ML :
  Tests statistiques, régression, classification, projet complet

================================================================================
FIN DU GUIDE — BONNE ANALYSE ! [PYTHON][GRAPHIQUE]
================================================================================